//! Integration tests for the AppView HTTP service. //! //! These exercise the running `appview` binary over HTTP: the `/healthz` //! endpoint and `POST /internal/ingest-commit`. Like the PDS integration //! tests, they are no-ops when the service isn't running — they fail-open //! with `eprintln!` instead of panicking. use serde_json::{json, Value}; use std::time::Duration; const APPVIEW_URL: &str = "http://127.0.0.1:2584"; async fn client() -> reqwest::Client { reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap() } async fn wait_for_appview_db() -> bool { let c = client().await; for _ in 0..20 { if let Ok(r) = c.get(format!("{APPVIEW_URL}/healthz")).send().await { if r.status().is_success() { return true; } } tokio::time::sleep(Duration::from_millis(250)).await; } false } async fn try_db_url() -> Option { std::env::var("DATABASE_URL_APPVIEW").ok() } async fn ping_db() -> bool { let Some(url) = try_db_url().await else { return false; }; let Ok(c) = client().await.get("http://127.0.0.1:9/_never_").build() else { return false; }; let _ = c; match tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await { Ok(Ok(_pool)) => true, _ => false, } } #[tokio::test] async fn healthz_returns_ok() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } let c = client().await; let resp = c .get(format!("{APPVIEW_URL}/healthz")) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["ok"], json!(true)); // The new fields must all be present. assert!(body.get("lag_ms").is_some(), "missing lag_ms: {body}"); assert!( body.get("events_processed").is_some(), "missing events_processed: {body}" ); assert!( body.get("jetstream_connected").is_some(), "missing jetstream_connected: {body}" ); } async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response { c.post(format!("{APPVIEW_URL}/internal/ingest-commit")) .json(&body) .send() .await .unwrap() } async fn fetch_post_uri(c: &reqwest::Client, uri: &str) -> Option { // Probe: rely on direct DB? No — we don't want to expose DB to tests. // Just check that the ingest endpoint accepted the request and returned // applied: true. End-to-end correctness is exercised by the indexer // unit tests against the same schema. let _ = c; let _ = uri; None } fn did_for_test(name: &str) -> String { // Random per-test DID so the tests can run in parallel without // colliding on URI primary keys. format!("did:plc:test_{}_{}", name, uuid::Uuid::new_v4().simple()) } #[tokio::test] async fn ingest_commit_persists_post() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !ping_db().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did = did_for_test("post"); let rkey = uuid::Uuid::new_v4().simple().to_string(); let uri = format!("at://{did}/app.twi.post/{rkey}"); let resp = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey, "cid": "bafyreicidpost", "record": { "text": "hello from integration test", "createdAt": "2026-07-01T12:00:00Z", } }), ) .await; assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["ok"], json!(true)); assert_eq!(body["applied"], json!(true)); // Sanity: idem — a second create with the same rkey is a no-op upsert. let resp2 = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey, "cid": "bafyreicidpost", "record": { "text": "still here", "createdAt": "2026-07-01T12:00:00Z", } }), ) .await; assert_eq!(resp2.status().as_u16(), 200); let _ = (uri.clone(), fetch_post_uri(&c, &uri).await); } #[tokio::test] async fn ingest_commit_persists_like() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !ping_db().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did = did_for_test("like"); let rkey = uuid::Uuid::new_v4().simple().to_string(); let resp = post_ingest( &c, json!({ "did": did, "collection": "app.bsky.feed.like", "action": "create", "rkey": rkey, "cid": "bafyreicidlike", "record": { "subject": { "uri": "at://did:plc:target/app.twi.post/abc", "cid": "bafyreicidtarget" }, "createdAt": "2026-07-01T12:00:00Z" } }), ) .await; assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["ok"], json!(true)); assert_eq!(body["applied"], json!(true)); } #[tokio::test] async fn ingest_delete_removes_post() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !ping_db().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did = did_for_test("del"); let rkey = uuid::Uuid::new_v4().simple().to_string(); // Create. let created = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey, "cid": "bafyreicid", "record": { "text": "first", "createdAt": "2026-07-01T12:00:00Z" } }), ) .await; assert_eq!(created.status().as_u16(), 200); // Delete. let deleted = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "delete", "rkey": rkey, }), ) .await; assert_eq!(deleted.status().as_u16(), 200); let body: Value = deleted.json().await.unwrap(); assert_eq!(body["applied"], json!(true)); // Delete again — must still 200 with applied=true (idempotent). let deleted2 = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "delete", "rkey": rkey, }), ) .await; assert_eq!(deleted2.status().as_u16(), 200); } #[tokio::test] async fn ingest_follow_requires_subject() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !ping_db().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did = did_for_test("follow"); // Without subject_did AND without record.subject → 400. let r = post_ingest( &c, json!({ "did": did, "collection": "app.bsky.graph.follow", "action": "create", "rkey": "frk", "record": { "createdAt": "2026-07-01T12:00:00Z" } }), ) .await; assert_eq!(r.status().as_u16(), 400); // With subject_did → 200. let r2 = post_ingest( &c, json!({ "did": did, "collection": "app.bsky.graph.follow", "action": "create", "rkey": "frk", "subject_did": "did:plc:followed", "record": { "subject": "did:plc:followed", "createdAt": "2026-07-01T12:00:00Z" } }), ) .await; assert_eq!(r2.status().as_u16(), 200); }