//! Integration tests for embed capture + thread hydration. //! //! These exercise the AppView's `embed` storage and the new //! `/api/post/{uri}` thread-hydration endpoint end-to-end: //! //! - `timeline_includes_embed` — seed a post with an image embed, query //! the home timeline, verify the embed came back as raw JSON. //! - `timeline_includes_external_embed` — same but with a link card. //! - `post_endpoint_returns_thread` — seed 3 posts (root + reply + reply //! to reply), fetch the middle one's URI, verify the thread //! hydration returns the right parent + root rows. //! //! Like the sibling API tests these are fail-open: if the AppView //! service isn't running on the expected port the test prints a notice //! and returns rather than panicking. The point of the tests is to //! catch regressions in CI where the service IS up. 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 db_reachable() -> bool { let Some(url) = std::env::var("DATABASE_URL_APPVIEW").ok() 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() } fn did_for_test(prefix: &str) -> String { format!( "did:plc:emb_{}_{}", prefix, uuid::Uuid::new_v4().simple() ) } fn rkey() -> String { uuid::Uuid::new_v4().simple().to_string() } /// Seed a single post with the given record payload and return its URI. async fn seed_post(c: &reqwest::Client, did: &str, record: Value) -> String { let rk = rkey(); let uri = format!("at://{did}/app.twi.post/{rk}"); let resp = post_ingest( c, json!({ "did": did, "collection": "app.twi.post", "action": "create", "rkey": rk, "cid": "bafyreicid", "record": record, }), ) .await; assert_eq!(resp.status().as_u16(), 200, "ingest failed: {record}"); uri } /// Seed a post whose parent/root URIs are given explicitly. Used by /// the thread test to build a 3-deep chain (root → reply → reply). async fn seed_reply( c: &reqwest::Client, did: &str, text: &str, parent_uri: &str, root_uri: &str, ) -> String { seed_post( c, did, json!({ "text": text, "createdAt": "2026-07-01T12:00:00Z", "reply": { "parent": {"uri": parent_uri, "cid": "cp"}, "root": {"uri": root_uri, "cid": "cr"} } }), ) .await } #[tokio::test] async fn timeline_includes_embed() { 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("img"); let uri = seed_post( &c, &did, json!({ "text": "look at this image", "createdAt": "2026-07-01T12:00:00Z", "embed": { "$type": "app.bsky.embed.images", "images": [ { "alt": "a sunset over mountains", "image": { "$type": "blob", "ref": {"$link": "bafyreimgres1"}, "mimeType": "image/jpeg", "size": 12345 }, "aspectRatio": {"width": 1200, "height": 800} }, { "alt": "second image", "image": { "$type": "blob", "ref": {"$link": "bafyreimgres2"}, "mimeType": "image/jpeg", "size": 6789 } } ] } }), ) .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().unwrap(); let our = posts .iter() .find(|p| p["uri"] == json!(uri)) .expect("seeded post missing from timeline"); let embed = our .get("embed") .expect("embed field missing from PostRow"); assert!(!embed.is_null(), "embed must not be null for image post"); assert_eq!(embed["$type"], "app.bsky.embed.images"); let imgs = embed["images"].as_array().expect("images array"); assert_eq!(imgs.len(), 2); assert_eq!(imgs[0]["alt"], "a sunset over mountains"); assert_eq!(imgs[0]["image"]["ref"]["$link"], "bafyreimgres1"); assert_eq!(imgs[0]["aspectRatio"]["width"], 1200); assert_eq!(imgs[1]["alt"], "second image"); } #[tokio::test] async fn timeline_includes_external_embed() { 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("ext"); let uri = seed_post( &c, &did, json!({ "text": "see link", "createdAt": "2026-07-01T12:00:00Z", "embed": { "$type": "app.bsky.embed.external", "external": { "uri": "https://example.com/article", "title": "An interesting article", "description": "A short description of the linked page.", "thumb": { "$type": "blob", "ref": {"$link": "bafyreithumb"} } } } }), ) .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().unwrap(); // The ingest endpoint commits asynchronously; the timeline may // not yet contain the row on the first poll. Retry briefly with // a 50ms back-off so we don't flake on busy CI. let mut our = posts.iter().find(|p| p["uri"] == json!(uri)).cloned(); for _ in 0..10 { if our.is_some() { break; } 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(); let body: Value = resp.json().await.unwrap(); our = body["posts"] .as_array() .unwrap() .iter() .find(|p| p["uri"] == json!(uri)) .cloned(); } let our = our.expect("seeded post missing from timeline"); let embed = our["embed"].as_object().expect("embed object"); assert_eq!(embed["$type"], "app.bsky.embed.external"); assert_eq!(embed["external"]["uri"], "https://example.com/article"); assert_eq!(embed["external"]["title"], "An interesting article"); } #[tokio::test] async fn post_endpoint_returns_thread() { 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("thread_alice"); let bob = did_for_test("thread_bob"); let carol = did_for_test("thread_carol"); // Build the chain: root (alice) → reply (bob) → reply to reply (carol). let root_uri = seed_post( &c, &alice, json!({ "text": "alice's root post", "createdAt": "2026-07-01T12:00:00Z" }), ) .await; let reply1_uri = seed_reply( &c, &bob, "bob's reply to alice", &root_uri, &root_uri, ) .await; let reply2_uri = seed_reply( &c, &carol, "carol's reply to bob", &reply1_uri, &root_uri, ) .await; // Fetch carol's post and verify the thread hydration returns both // bob's reply (parent) and alice's root (root). let resp = c .get(format!("{APPVIEW_URL}/api/post/{reply2_uri}")) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["post"]["uri"], json!(reply2_uri)); assert_eq!( body["post"]["text"], json!("carol's reply to bob") ); let parent = &body["thread"]["parent"]; let root = &body["thread"]["root"]; assert_eq!(parent["uri"], json!(reply1_uri)); assert_eq!(parent["text"], json!("bob's reply to alice")); assert_eq!(root["uri"], json!(root_uri)); assert_eq!(root["text"], json!("alice's root post")); // Reply → reply case: carol's `parent_uri` is bob's, `root_uri` is // alice's, and they must differ — so the root field must NOT be // collapsed into the parent field. assert_ne!( parent["uri"], root["uri"], "root and parent must be distinct rows for a 2-deep reply chain" ); } #[tokio::test] async fn post_endpoint_single_post_thread_self_referential() { 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("self"); let uri = seed_post( &c, &did, json!({ "text": "standalone post, no parent", "createdAt": "2026-07-01T12:00:00Z" }), ) .await; let resp = c .get(format!("{APPVIEW_URL}/api/post/{uri}")) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["post"]["uri"], json!(uri)); assert!( body["thread"]["parent"].is_null(), "post with no parent must have null parent" ); assert!( body["thread"]["root"].is_null(), "post with no parent must have null root" ); } #[tokio::test] async fn post_endpoint_unknown_uri_returns_null_post() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } let c = client().await; let bogus = format!( "at://did:plc:nope-{}/app.twi.post/nope-{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple() ); let resp = c .get(format!("{APPVIEW_URL}/api/post/{bogus}")) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); assert!(body["post"].is_null()); assert!(body["thread"]["parent"].is_null()); assert!(body["thread"]["root"].is_null()); } #[tokio::test] async fn timeline_post_without_embed_has_null_embed() { 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("plain"); let uri = seed_post( &c, &did, json!({ "text": "plain text only", "createdAt": "2026-07-01T12:00:00Z" }), ) .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 our = body["posts"] .as_array() .unwrap() .iter() .find(|p| p["uri"] == json!(uri)) .expect("plain post missing"); assert!( our["embed"].is_null(), "plain text post must have null embed, got: {}", our["embed"] ); }