//! Integration tests for the `/api/post/{uri}` engagement counts. //! //! Like the sibling `embeds_integration.rs` these are fail-open //! against a live AppView: the tests `eprintln!` and skip if the //! service isn't running on the expected port or the DB is //! unreachable. //! //! Tests: //! //! - `post_endpoint_returns_like_counts` — seed a like via //! `/internal/ingest-commit`, fetch the post endpoint, verify //! the `like_count` is 1. Seed a repost, verify the //! `repost_count` is 1. 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:likes_{}_{}", prefix, uuid::Uuid::new_v4().simple() ) } fn rkey() -> String { uuid::Uuid::new_v4().simple().to_string() } async fn seed_post(c: &reqwest::Client, did: &str, text: &str) -> 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": { "text": text, "createdAt": "2026-07-01T12:00:00Z", }, }), ) .await; assert_eq!(resp.status().as_u16(), 200, "post ingest failed"); uri } async fn seed_like( c: &reqwest::Client, liker_did: &str, subject_uri: &str, subject_cid: &str, ) -> String { let rk = rkey(); let like_uri = format!("at://{liker_did}/app.bsky.feed.like/{rk}"); let resp = post_ingest( c, json!({ "did": liker_did, "collection": "app.bsky.feed.like", "action": "create", "rkey": rk, "cid": "bafyreilike", "record": { "subject": { "uri": subject_uri, "cid": subject_cid, }, "createdAt": "2026-07-01T12:00:00Z", }, }), ) .await; assert_eq!(resp.status().as_u16(), 200, "like ingest failed"); like_uri } async fn seed_repost( c: &reqwest::Client, reposter_did: &str, subject_uri: &str, subject_cid: &str, ) -> String { let rk = rkey(); let repost_uri = format!("at://{reposter_did}/app.bsky.feed.repost/{rk}"); let resp = post_ingest( c, json!({ "did": reposter_did, "collection": "app.bsky.feed.repost", "action": "create", "rkey": rk, "cid": "bafyreirepost", "record": { "subject": { "uri": subject_uri, "cid": subject_cid, }, "createdAt": "2026-07-01T12:00:00Z", }, }), ) .await; assert_eq!(resp.status().as_u16(), 200, "repost ingest failed"); repost_uri } /// Poll the post endpoint a few times so we don't flake on /// ingestion latency. The ingest-commit handler is async, so /// counts may not be visible on the first request. async fn post_endpoint_with_counts( c: &reqwest::Client, uri: &str, ) -> Option { for _ in 0..10 { let resp = c .get(format!("{APPVIEW_URL}/api/post/{uri}")) .send() .await .ok()?; if !resp.status().is_success() { tokio::time::sleep(Duration::from_millis(50)).await; continue; } let body: Value = resp.json().await.ok()?; if body.get("like_count").is_some() || body.get("repost_count").is_some() { return Some(body); } tokio::time::sleep(Duration::from_millis(50)).await; } None } #[tokio::test] async fn post_endpoint_returns_like_counts() { 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 author = did_for_test("author"); let liker = did_for_test("liker"); let reposter = did_for_test("reposter"); // The post endpoint needs the post itself in the `posts` table // to return a non-null `post` and the engagement counts. The // embed for the post is fine to be null. let post_uri = seed_post(&c, &author, "a post that will get engagement").await; let post_cid = "bafyreicid"; // No likes / reposts yet — counts must be 0. let initial = post_endpoint_with_counts(&c, &post_uri) .await .expect("post endpoint never resolved"); assert_eq!( initial["post"]["uri"], json!(post_uri), "endpoint should return our seeded post" ); assert_eq!(initial["like_count"], json!(0), "initial like_count"); assert_eq!(initial["repost_count"], json!(0), "initial repost_count"); // Seed one like and one repost from different DIDs. let _ = seed_like(&c, &liker, &post_uri, post_cid).await; let _ = seed_repost(&c, &reposter, &post_uri, post_cid).await; let body = post_endpoint_with_counts(&c, &post_uri) .await .expect("post endpoint never resolved after engagement"); assert_eq!( body["like_count"], json!(1), "like_count should be 1 after one like ingest: {body:?}" ); assert_eq!( body["repost_count"], json!(1), "repost_count should be 1 after one repost ingest: {body:?}" ); } #[tokio::test] async fn post_endpoint_missing_post_returns_null_counts() { if !wait_for_appview_db().await { eprintln!("appview not running, skipping"); return; } let c = client().await; let bogus = format!( "at://did:plc:nope_lc_{}/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(), "missing post should be null"); // Counts are skipped when the post isn't found — we shouldn't // pay the `COUNT(*)` cost on the "not in index" path. assert!( body.get("like_count").is_none() || body["like_count"].is_null(), "like_count should be absent for missing post, got: {body:?}" ); assert!( body.get("repost_count").is_none() || body["repost_count"].is_null(), "repost_count should be absent for missing post, got: {body:?}" ); } /// Phase 5b review H4 — `viewer_liked` / `viewer_reposted` must reach /// the UI so it can highlight the engagement buttons. Without this /// the Tauri client can show the counts but never knows whether the /// user has already liked/reposted the post, so the "liked" state /// doesn't persist visually across reloads. #[tokio::test] async fn post_endpoint_with_viewer_did_returns_liked_state() { 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 author = did_for_test("vl_author"); let liker = did_for_test("vl_liker"); let reposter = did_for_test("vl_reposter"); let outsider = did_for_test("vl_outsider"); let post_uri = seed_post(&c, &author, "post that some viewers like").await; let post_cid = "bafyreicid"; // Seed a like from `liker` and a repost from `reposter`. let _ = seed_like(&c, &liker, &post_uri, post_cid).await; let _ = seed_repost(&c, &reposter, &post_uri, post_cid).await; // Poll the endpoint with `viewer_did=liker` and verify // `viewer_liked = true` and `viewer_reposted = false` (liker did // not repost). let mut body_liker: Option = None; for _ in 0..20 { let resp = c .get(format!( "{APPVIEW_URL}/api/post/{post_uri}" )) .query(&[("viewer_did", liker.as_str())]) .send() .await .unwrap(); assert_eq!(resp.status().as_u16(), 200); let body: Value = resp.json().await.unwrap(); if body.get("viewer_liked").is_some() { body_liker = Some(body); break; } tokio::time::sleep(Duration::from_millis(50)).await; } let body_liker = body_liker.expect("viewer_liked never appeared"); assert_eq!( body_liker["viewer_liked"], json!(true), "viewer_liker should see viewer_liked=true: {body_liker:?}" ); assert_eq!( body_liker["viewer_reposted"], json!(false), "viewer_liker did not repost: {body_liker:?}" ); assert_eq!(body_liker["like_count"], json!(1)); assert_eq!(body_liker["repost_count"], json!(1)); // Now query with `viewer_did=reposter`: opposite state. let body_reposter: Value = c .get(format!("{APPVIEW_URL}/api/post/{post_uri}")) .query(&[("viewer_did", reposter.as_str())]) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(body_reposter["viewer_liked"], json!(false)); assert_eq!(body_reposter["viewer_reposted"], json!(true)); // And `viewer_did=outsider` (no engagement) → both false. let body_outsider: Value = c .get(format!("{APPVIEW_URL}/api/post/{post_uri}")) .query(&[("viewer_did", outsider.as_str())]) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(body_outsider["viewer_liked"], json!(false)); assert_eq!(body_outsider["viewer_reposted"], json!(false)); // Without `viewer_did`, the booleans should be absent (the client // renders "unknown" state). The counts still come back so the UI // can show "1 like". let body_anonymous: Value = c .get(format!("{APPVIEW_URL}/api/post/{post_uri}")) .send() .await .unwrap() .json() .await .unwrap(); assert!( body_anonymous.get("viewer_liked").is_none(), "viewer_liked should be absent without viewer_did: {body_anonymous:?}" ); assert!( body_anonymous.get("viewer_reposted").is_none(), "viewer_reposted should be absent without viewer_did: {body_anonymous:?}" ); }