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,443 @@
|
||||
//! Integration tests for embed capture + thread hydration.
|
||||
//!
|
||||
//! These exercise the AppView's `embed` storage and the new
|
||||
//! `/api/post/{uri}` thread-hydration endpoint end-to-end:
|
||||
//!
|
||||
//! - `timeline_includes_embed` — seed a post with an image embed, query
|
||||
//! the home timeline, verify the embed came back as raw JSON.
|
||||
//! - `timeline_includes_external_embed` — same but with a link card.
|
||||
//! - `post_endpoint_returns_thread` — seed 3 posts (root + reply + reply
|
||||
//! to reply), fetch the middle one's URI, verify the thread
|
||||
//! hydration returns the right parent + root rows.
|
||||
//!
|
||||
//! Like the sibling API tests these are fail-open: if the AppView
|
||||
//! service isn't running on the expected port the test prints a notice
|
||||
//! and returns rather than panicking. The point of the tests is to
|
||||
//! catch regressions in CI where the service IS up.
|
||||
|
||||
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:emb_{}_{}",
|
||||
prefix,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
)
|
||||
}
|
||||
|
||||
fn rkey() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// Seed a single post with the given record payload and return its URI.
|
||||
async fn seed_post(c: &reqwest::Client, did: &str, record: Value) -> 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": record,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status().as_u16(), 200, "ingest failed: {record}");
|
||||
uri
|
||||
}
|
||||
|
||||
/// Seed a post whose parent/root URIs are given explicitly. Used by
|
||||
/// the thread test to build a 3-deep chain (root → reply → reply).
|
||||
async fn seed_reply(
|
||||
c: &reqwest::Client,
|
||||
did: &str,
|
||||
text: &str,
|
||||
parent_uri: &str,
|
||||
root_uri: &str,
|
||||
) -> String {
|
||||
seed_post(
|
||||
c,
|
||||
did,
|
||||
json!({
|
||||
"text": text,
|
||||
"createdAt": "2026-07-01T12:00:00Z",
|
||||
"reply": {
|
||||
"parent": {"uri": parent_uri, "cid": "cp"},
|
||||
"root": {"uri": root_uri, "cid": "cr"}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeline_includes_embed() {
|
||||
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("img");
|
||||
let uri = seed_post(
|
||||
&c,
|
||||
&did,
|
||||
json!({
|
||||
"text": "look at this image",
|
||||
"createdAt": "2026-07-01T12:00:00Z",
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.images",
|
||||
"images": [
|
||||
{
|
||||
"alt": "a sunset over mountains",
|
||||
"image": {
|
||||
"$type": "blob",
|
||||
"ref": {"$link": "bafyreimgres1"},
|
||||
"mimeType": "image/jpeg",
|
||||
"size": 12345
|
||||
},
|
||||
"aspectRatio": {"width": 1200, "height": 800}
|
||||
},
|
||||
{
|
||||
"alt": "second image",
|
||||
"image": {
|
||||
"$type": "blob",
|
||||
"ref": {"$link": "bafyreimgres2"},
|
||||
"mimeType": "image/jpeg",
|
||||
"size": 6789
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
)
|
||||
.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().unwrap();
|
||||
|
||||
let our = posts
|
||||
.iter()
|
||||
.find(|p| p["uri"] == json!(uri))
|
||||
.expect("seeded post missing from timeline");
|
||||
|
||||
let embed = our
|
||||
.get("embed")
|
||||
.expect("embed field missing from PostRow");
|
||||
assert!(!embed.is_null(), "embed must not be null for image post");
|
||||
assert_eq!(embed["$type"], "app.bsky.embed.images");
|
||||
let imgs = embed["images"].as_array().expect("images array");
|
||||
assert_eq!(imgs.len(), 2);
|
||||
assert_eq!(imgs[0]["alt"], "a sunset over mountains");
|
||||
assert_eq!(imgs[0]["image"]["ref"]["$link"], "bafyreimgres1");
|
||||
assert_eq!(imgs[0]["aspectRatio"]["width"], 1200);
|
||||
assert_eq!(imgs[1]["alt"], "second image");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeline_includes_external_embed() {
|
||||
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("ext");
|
||||
let uri = seed_post(
|
||||
&c,
|
||||
&did,
|
||||
json!({
|
||||
"text": "see link",
|
||||
"createdAt": "2026-07-01T12:00:00Z",
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.external",
|
||||
"external": {
|
||||
"uri": "https://example.com/article",
|
||||
"title": "An interesting article",
|
||||
"description": "A short description of the linked page.",
|
||||
"thumb": {
|
||||
"$type": "blob",
|
||||
"ref": {"$link": "bafyreithumb"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.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().unwrap();
|
||||
|
||||
// The ingest endpoint commits asynchronously; the timeline may
|
||||
// not yet contain the row on the first poll. Retry briefly with
|
||||
// a 50ms back-off so we don't flake on busy CI.
|
||||
let mut our = posts.iter().find(|p| p["uri"] == json!(uri)).cloned();
|
||||
for _ in 0..10 {
|
||||
if our.is_some() {
|
||||
break;
|
||||
}
|
||||
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();
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
our = body["posts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["uri"] == json!(uri))
|
||||
.cloned();
|
||||
}
|
||||
let our = our.expect("seeded post missing from timeline");
|
||||
let embed = our["embed"].as_object().expect("embed object");
|
||||
assert_eq!(embed["$type"], "app.bsky.embed.external");
|
||||
assert_eq!(embed["external"]["uri"], "https://example.com/article");
|
||||
assert_eq!(embed["external"]["title"], "An interesting article");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_endpoint_returns_thread() {
|
||||
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("thread_alice");
|
||||
let bob = did_for_test("thread_bob");
|
||||
let carol = did_for_test("thread_carol");
|
||||
|
||||
// Build the chain: root (alice) → reply (bob) → reply to reply (carol).
|
||||
let root_uri = seed_post(
|
||||
&c,
|
||||
&alice,
|
||||
json!({
|
||||
"text": "alice's root post",
|
||||
"createdAt": "2026-07-01T12:00:00Z"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let reply1_uri = seed_reply(
|
||||
&c,
|
||||
&bob,
|
||||
"bob's reply to alice",
|
||||
&root_uri,
|
||||
&root_uri,
|
||||
)
|
||||
.await;
|
||||
let reply2_uri = seed_reply(
|
||||
&c,
|
||||
&carol,
|
||||
"carol's reply to bob",
|
||||
&reply1_uri,
|
||||
&root_uri,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Fetch carol's post and verify the thread hydration returns both
|
||||
// bob's reply (parent) and alice's root (root).
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/post/{reply2_uri}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["post"]["uri"], json!(reply2_uri));
|
||||
assert_eq!(
|
||||
body["post"]["text"],
|
||||
json!("carol's reply to bob")
|
||||
);
|
||||
|
||||
let parent = &body["thread"]["parent"];
|
||||
let root = &body["thread"]["root"];
|
||||
assert_eq!(parent["uri"], json!(reply1_uri));
|
||||
assert_eq!(parent["text"], json!("bob's reply to alice"));
|
||||
assert_eq!(root["uri"], json!(root_uri));
|
||||
assert_eq!(root["text"], json!("alice's root post"));
|
||||
|
||||
// Reply → reply case: carol's `parent_uri` is bob's, `root_uri` is
|
||||
// alice's, and they must differ — so the root field must NOT be
|
||||
// collapsed into the parent field.
|
||||
assert_ne!(
|
||||
parent["uri"], root["uri"],
|
||||
"root and parent must be distinct rows for a 2-deep reply chain"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_endpoint_single_post_thread_self_referential() {
|
||||
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("self");
|
||||
let uri = seed_post(
|
||||
&c,
|
||||
&did,
|
||||
json!({
|
||||
"text": "standalone post, no parent",
|
||||
"createdAt": "2026-07-01T12:00:00Z"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/post/{uri}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["post"]["uri"], json!(uri));
|
||||
assert!(
|
||||
body["thread"]["parent"].is_null(),
|
||||
"post with no parent must have null parent"
|
||||
);
|
||||
assert!(
|
||||
body["thread"]["root"].is_null(),
|
||||
"post with no parent must have null root"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_endpoint_unknown_uri_returns_null_post() {
|
||||
if !wait_for_appview_db().await {
|
||||
eprintln!("appview not running, skipping");
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let bogus = format!(
|
||||
"at://did:plc:nope-{}/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());
|
||||
assert!(body["thread"]["parent"].is_null());
|
||||
assert!(body["thread"]["root"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeline_post_without_embed_has_null_embed() {
|
||||
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("plain");
|
||||
let uri = seed_post(
|
||||
&c,
|
||||
&did,
|
||||
json!({
|
||||
"text": "plain text only",
|
||||
"createdAt": "2026-07-01T12:00:00Z"
|
||||
}),
|
||||
)
|
||||
.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 our = body["posts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["uri"] == json!(uri))
|
||||
.expect("plain post missing");
|
||||
assert!(
|
||||
our["embed"].is_null(),
|
||||
"plain text post must have null embed, got: {}",
|
||||
our["embed"]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user