//! Integration tests for the new read API routes //! (`/api/timeline/home`, `/api/profile/...`, `/api/search`). //! //! These run against a live appview service + DB. Like //! `appview_integration.rs`, they're fail-open: if the service or DB //! isn't reachable, the test prints a notice and returns success //! rather than panicking — so `cargo test --workspace` stays green in //! environments where the appview hasn't been started. 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 db_reachable() -> bool { let Some(url) = try_db_url().await else { return false; }; matches!( tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await, Ok(Ok(_)) ) } async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response { c.post(format!("{APPVIEW_URL}/internal/ingest-commit")) .json(&body) .send() .await .unwrap() } /// Insert a follow row directly via the DB. We bypass the ingest /// endpoint because (a) 1500 individual HTTP round-trips are /// prohibitively slow for the cap test, and (b) we don't need the /// indexer to also re-resolve handles etc. for this test. async fn insert_follow( pool: &sqlx::PgPool, follower_did: &str, subject_did: &str, ) { sqlx::query( r#"INSERT INTO follows (follower_did, subject_did, created_at) VALUES ($1, $2, now()) ON CONFLICT (follower_did, subject_did) DO NOTHING"#, ) .bind(follower_did) .bind(subject_did) .execute(pool) .await .unwrap(); } /// Seed N posts for a DID with sequential rkeys and `created_at` /// timestamps that strictly increase, so the cursor ordering test is /// deterministic. async fn seed_posts(c: &reqwest::Client, did: &str, texts: &[&str]) { for (i, text) in texts.iter().enumerate() { let r = post_ingest( c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey(), "cid": "bafyreicid", "record": { "text": text, "createdAt": format!("2026-07-01T12:00:{:02}Z", i), } }), ) .await; assert_eq!(r.status().as_u16(), 200); } } 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()) } fn rkey() -> String { uuid::Uuid::new_v4().simple().to_string() } #[tokio::test] async fn timeline_returns_seeded_posts() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !db_reachable().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did = did_for_test("tl"); // Seed 3 posts with distinct rkeys. for i in 0..3 { let r = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey(), "cid": "bafyreicid", "record": { "text": format!("seeded post #{i}"), "createdAt": "2026-07-01T12:00:00Z", } }), ) .await; assert_eq!(r.status().as_u16(), 200); } // Give Jetstream / ingest a beat to settle — `indexed_at` defaults // to `now()` on insert, so we want a non-zero chance of seeing all // three rows in the first page. tokio::time::sleep(Duration::from_millis(50)).await; let resp = c .get(format!("{APPVIEW_URL}/api/timeline/home")) .query(&[("did", did.as_str()), ("limit", "10")]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); let posts = body["posts"].as_array().expect("posts is array"); assert!(posts.len() >= 3, "expected >=3 posts, got {}", posts.len()); // All three seeded posts must be in the response and all share the // same DID. let our_uris: Vec<&str> = posts .as_slice() .iter() .filter_map(|p| { let uri = p["uri"].as_str()?; if uri.starts_with(&format!("at://{did}/")) { Some(uri) } else { None } }) .collect(); assert!(our_uris.len() >= 3, "missing our seeded posts in {posts:?}"); // Posts must be sorted with `indexed_at DESC`. We can't see // indexed_at directly in the response, but the URI order in // `app.twi.post/` is rkey-random here, so we only assert // `created_at` is non-increasing. let mut prev: Option = None; for p in posts { let ca = p["createdAt"].as_str().unwrap().to_string(); if let Some(p) = prev.take() { assert!(ca <= p, "createdAt must be non-increasing: {ca} <= {p}"); } prev = Some(ca); } } #[tokio::test] async fn timeline_paginates_with_cursor() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !db_reachable().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did = did_for_test("pg"); // Seed 50 posts. for _ in 0..50 { let r = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey(), "cid": "bafyreicid", "record": { "text": "page", "createdAt": "2026-07-01T12:00:00Z", } }), ) .await; assert_eq!(r.status().as_u16(), 200); } tokio::time::sleep(Duration::from_millis(100)).await; // Page 1: limit=20. let resp = c .get(format!("{APPVIEW_URL}/api/timeline/home")) .query(&[("did", did.as_str()), ("limit", "20")]) .send() .await .unwrap(); let body: Value = resp.json().await.unwrap(); let page1 = body["posts"].as_array().unwrap().clone(); let cursor1 = body["cursor"].as_str().expect("page1 cursor"); assert_eq!(page1.len(), 20, "page1 should be exactly 20"); // Page 2: with cursor. let resp = c .get(format!("{APPVIEW_URL}/api/timeline/home")) .query(&[ ("did", did.as_str()), ("limit", "20"), ("cursor", cursor1), ]) .send() .await .unwrap(); let body: Value = resp.json().await.unwrap(); let page2 = body["posts"].as_array().unwrap().clone(); assert_eq!(page2.len(), 20, "page2 should be exactly 20"); // Pages must not overlap. let p1: std::collections::HashSet<&str> = page1 .iter() .map(|p| p["uri"].as_str().unwrap()) .collect(); let p2: std::collections::HashSet<&str> = page2 .iter() .map(|p| p["uri"].as_str().unwrap()) .collect(); assert!(p1.is_disjoint(&p2), "page1 and page2 overlap"); // Page 3: tail — fewer than 20 expected, cursor=null. let cursor2 = body["cursor"].as_str().expect("page2 cursor"); let resp = c .get(format!("{APPVIEW_URL}/api/timeline/home")) .query(&[ ("did", did.as_str()), ("limit", "20"), ("cursor", cursor2), ]) .send() .await .unwrap(); let body: Value = resp.json().await.unwrap(); let page3 = body["posts"].as_array().unwrap().clone(); assert!(page3.len() <= 20, "page3 should be <= 20"); // At least one of the three pages should be non-empty. assert!(!page1.is_empty() || !page2.is_empty() || !page3.is_empty()); } #[tokio::test] async fn profile_returns_posts_for_handle() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !db_reachable().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did_a = did_for_test("alice"); let did_b = did_for_test("bob"); let handle_a = format!("alice.{}", uuid::Uuid::new_v4().simple()); // Seed a post for A with handle populated, and a post for B with a // different handle. The internal-ingest path doesn't expose a // `handle` field, so we update the column directly. for did in [&did_a, &did_b] { let r = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey(), "cid": "bafyreicid", "record": { "text": "hi", "createdAt": "2026-07-01T12:00:00Z", } }), ) .await; assert_eq!(r.status().as_u16(), 200); } // Backfill handle for A only. let url = std::env::var("DATABASE_URL_APPVIEW").unwrap(); let pool = sqlx::PgPool::connect(&url).await.unwrap(); sqlx::query("UPDATE posts SET handle = $1 WHERE did = $2") .bind(&handle_a) .bind(&did_a) .execute(&pool) .await .unwrap(); // Query by handle (no leading @). let resp = c .get(format!("{APPVIEW_URL}/api/profile/{handle_a}")) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["did"], json!(did_a)); assert_eq!(body["handle"], json!(handle_a)); let posts = body["posts"].as_array().unwrap(); assert!( posts.iter().any(|p| p["did"] == json!(did_a)), "did_a post missing from profile" ); assert!( !posts.iter().any(|p| p["did"] == json!(did_b)), "did_b post leaked into alice's profile" ); // Same query, with leading @ — must also work. let resp = c .get(format!("{APPVIEW_URL}/api/profile/@{handle_a}")) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); // 404 for an unknown handle. let resp = c .get(format!( "{APPVIEW_URL}/api/profile/nobody_{}", uuid::Uuid::new_v4().simple() )) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 404); // /api/profile?did=... must work too. let resp = c .get(format!("{APPVIEW_URL}/api/profile")) .query(&[("did", did_a.as_str())]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["did"], json!(did_a)); } #[tokio::test] async fn search_finds_text_match() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !db_reachable().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let did = did_for_test("srch"); for text in ["hello world from test", "goodbye cruel world", "x"] { let r = post_ingest( &c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rkey(), "cid": "bafyreicid", "record": { "text": text, "createdAt": "2026-07-01T12:00:00Z", } }), ) .await; assert_eq!(r.status().as_u16(), 200); } tokio::time::sleep(Duration::from_millis(50)).await; let resp = c .get(format!("{APPVIEW_URL}/api/search")) .query(&[("q", "hello"), ("limit", "10")]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["q"], json!("hello")); let posts = body["posts"].as_array().unwrap(); assert!(!posts.is_empty(), "expected at least one match for 'hello'"); for p in posts { let t = p["text"].as_str().unwrap(); assert!( t.to_lowercase().contains("hello"), "post in result doesn't contain 'hello': {t}" ); } // Empty q is a 400. let resp = c .get(format!("{APPVIEW_URL}/api/search")) .query(&[("q", "")]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 400); } /// When alice follows bob and carol but NOT dave, her home timeline /// must show bob's and carol's posts only — dave's post is invisible /// to her even though it sits in the global recent feed. #[tokio::test] async fn timeline_filters_to_followees() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !db_reachable().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let url = std::env::var("DATABASE_URL_APPVIEW").unwrap(); let pool = sqlx::PgPool::connect(&url).await.unwrap(); let alice = did_for_test("alice"); let bob = did_for_test("bob"); let carol = did_for_test("carol"); let dave = did_for_test("dave"); // Alice follows bob + carol (NOT dave). insert_follow(&pool, &alice, &bob).await; insert_follow(&pool, &alice, &carol).await; // Each person posts once. seed_posts(&c, &bob, &["bob says hi"]).await; seed_posts(&c, &carol, &["carol says hi"]).await; seed_posts(&c, &dave, &["dave says hi (alice should NOT see this)"]).await; tokio::time::sleep(Duration::from_millis(100)).await; let resp = c .get(format!("{APPVIEW_URL}/api/timeline/home")) .query(&[("did", alice.as_str()), ("limit", "100")]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); let posts = body["posts"].as_array().expect("posts is array"); // Collect DIDs of returned posts. let returned_dids: std::collections::HashSet = posts .iter() .map(|p| p["did"].as_str().unwrap().to_string()) .collect(); // Bob and carol MUST be present; dave MUST NOT be. assert!( returned_dids.contains(&bob), "bob's post missing from alice's timeline: {posts:?}" ); assert!( returned_dids.contains(&carol), "carol's post missing from alice's timeline: {posts:?}" ); assert!( !returned_dids.contains(&dave), "dave's post leaked into alice's timeline: {posts:?}" ); // Stronger: walk every post and assert no `did` matches dave. for p in posts { let did = p["did"].as_str().unwrap(); assert_ne!(did, dave, "dave leaked: {p:?}"); } } /// Alice posts without following anyone. The endpoint must still /// surface her own posts — via the global-recent "cold start" /// fallback — so a brand-new account with no follows can see what /// they've posted. #[tokio::test] async fn timeline_includes_own_posts() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !db_reachable().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let alice = did_for_test("alone"); // Alice posts without seeding any follows. seed_posts(&c, &alice, &["alice's first post", "alice's second post"]).await; tokio::time::sleep(Duration::from_millis(100)).await; let resp = c .get(format!("{APPVIEW_URL}/api/timeline/home")) .query(&[("did", alice.as_str()), ("limit", "100")]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); let posts = body["posts"].as_array().expect("posts is array"); // At least alice's two posts must be present. The global fallback // will include other recent posts from the DB too — we only // assert on alice's visibility here. let alice_uris: Vec<&str> = posts .iter() .filter_map(|p| { let uri = p["uri"].as_str()?; if uri.starts_with(&format!("at://{alice}/")) { Some(uri) } else { None } }) .collect(); assert!( alice_uris.len() >= 2, "alice's own posts missing from her own timeline: {posts:?}" ); } /// Alice follows 1500 fake DIDs. The endpoint must NOT blow up — the /// `target_dids` cap at MAX_FOLLOWED_DIDS=1000 kicks in, the user's /// own DID is re-inserted, and the SQL `ANY($)` array stays bounded. #[tokio::test] async fn timeline_caps_followee_list() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } if !db_reachable().await { eprintln!("appview DB unreachable, skipping"); return; } let c = client().await; let url = std::env::var("DATABASE_URL_APPVIEW").unwrap(); let pool = sqlx::PgPool::connect(&url).await.unwrap(); let alice = did_for_test("poweruser"); // Seed 1500 follows (well over MAX_FOLLOWED_DIDS=1000). for _ in 0..1500 { let fake = format!( "did:plc:fake_{}_{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple() ); insert_follow(&pool, &alice, &fake).await; } // Alice also posts — to confirm she sees her own DID even though // the cap trimmed the lexically-greatest 1000 followees. seed_posts(&c, &alice, &["poweruser post"]).await; tokio::time::sleep(Duration::from_millis(100)).await; let resp = c .get(format!("{APPVIEW_URL}/api/timeline/home")) .query(&[("did", alice.as_str()), ("limit", "50")]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); let posts = body["posts"].as_array().expect("posts is array"); // Alice's own post must be visible — the cap invariant guarantees // her own DID is preserved. let alice_visible = posts .iter() .any(|p| p["did"].as_str() == Some(alice.as_str())); assert!( alice_visible, "alice's own post not visible after cap: {posts:?}" ); // The fake followee DIDs have no posts, so nothing else should // leak in. We only assert the endpoint didn't error and that // alice's own DID is honored. }