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.
620 lines
19 KiB
Rust
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.
|
|
}
|