Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.
Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
umgebende Like-Transaktion abbrechen, also das Like wegen eines
Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.
Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.
Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.
/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.
Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
651 lines
21 KiB
Rust
651 lines
21 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}"),
|
|
// Strictly increasing so the ordering assertion
|
|
// below has something to actually check: the rows
|
|
// are inserted in this order, so `indexed_at` and
|
|
// `created_at` agree for *our* posts.
|
|
"createdAt": format!("2026-07-01T12:00:{:02}Z", i),
|
|
}
|
|
}),
|
|
)
|
|
.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;
|
|
|
|
// `limit=100`, not 10: this DID follows nobody, so the endpoint
|
|
// serves the cold-start *global* recent feed. On any database with
|
|
// more than a handful of recent posts (i.e. every developer
|
|
// machine that has run this suite twice) the three rows we just
|
|
// seeded fall outside a 10-row window and the assertions below
|
|
// fail for reasons that have nothing to do with the timeline.
|
|
let resp = c
|
|
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
|
.query(&[("did", did.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");
|
|
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 come back `indexed_at DESC`, and we can't see `indexed_at`
|
|
// in the response — but for the three rows WE just inserted,
|
|
// insertion order == `indexed_at` order == `created_at` order, so
|
|
// their `created_at` values must be non-increasing.
|
|
//
|
|
// Two fixes over the original assertion:
|
|
// - the wire field is `created_at`, not `createdAt` (the
|
|
// `PostRow` wire type in `routes/types.rs` carries no
|
|
// `rename_all = "camelCase"`), so `p["createdAt"]` was JSON
|
|
// `null` and `.as_str().unwrap()` panicked on the first row;
|
|
// - it ran over ALL posts, including other tests' fixtures from
|
|
// the global cold-start feed, whose `created_at` values have
|
|
// no relation to their `indexed_at` order. Restricting it to
|
|
// our own DID is the only version of this claim that holds.
|
|
let mut prev: Option<String> = None;
|
|
for p in posts.iter().filter(|p| p["did"] == json!(did)) {
|
|
let ca = p["created_at"].as_str().unwrap().to_string();
|
|
if let Some(prev) = prev.take() {
|
|
assert!(
|
|
ca <= prev,
|
|
"created_at must be non-increasing: {ca} <= {prev}"
|
|
);
|
|
}
|
|
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);
|
|
|
|
// An unknown handle is NOT a 404. `resolve_profile` deliberately
|
|
// synthesises an empty profile (empty `did`, zero counts, no
|
|
// posts) so the UI renders an empty profile page instead of an
|
|
// error toast — see the comment on the `let Some(target_did)`
|
|
// else-branch in `routes.rs`. This assertion used to expect 404
|
|
// and contradicted the endpoint it was testing.
|
|
let unknown = format!("nobody_{}", uuid::Uuid::new_v4().simple());
|
|
let resp = c
|
|
.get(format!("{APPVIEW_URL}/api/profile/{unknown}"))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 200);
|
|
let body: Value = resp.json().await.unwrap();
|
|
assert_eq!(body["did"], json!(""));
|
|
assert_eq!(body["handle"], json!(unknown));
|
|
assert!(body["posts"].as_array().unwrap().is_empty());
|
|
assert_eq!(body["post_count"], json!(0));
|
|
|
|
// /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.
|
|
}
|