maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
//! 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<Value> {
|
||||
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<Value> = 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:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user