Files
maarcadetweet/crates/appview/tests/api_integration.rs
T
tomdebone c586fd39c9 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.
2026-07-05 20:01:31 +02:00

620 lines
19 KiB
Rust

//! 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<String> {
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/<rkey>` is rkey-random here, so we only assert
// `created_at` is non-increasing.
let mut prev: Option<String> = 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<String> = 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.
}