Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534mod common;
use axum::body::Body;use axum::http::{Request, StatusCode};use happyview::db::{adapt_sql, now_rfc3339};use http_body_util::BodyExt;use serde_json::{Value, json};use serial_test::serial;use tower::ServiceExt;use wiremock::matchers::{method, path, path_regex};use wiremock::{Mock, ResponseTemplate};
use common::app::TestApp;use common::fixtures;
// ---------------------------------------------------------------------------// Helpers// ---------------------------------------------------------------------------
async fn json_body(resp: axum::response::Response) -> Value { let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap()}
fn admin_post( uri: &str, cookie: (axum::http::HeaderName, axum::http::HeaderValue), body: &Value,) -> Request<Body> { Request::builder() .method("POST") .uri(uri) .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap()}
async fn seed_lexicons(app: &TestApp) { // Record lexicon app.router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": false }), )) .await .unwrap();
// Query lexicon app.router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::list_games_query_lexicon(), "target_collection": "games.gamesgamesgamesgames.game" }), )) .await .unwrap();
// Procedure lexicon app.router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::create_game_procedure_lexicon(), "target_collection": "games.gamesgamesgamesgames.game" }), )) .await .unwrap();}
async fn seed_record(app: &TestApp, uri: &str, did: &str, collection: &str, record: &Value) { let rkey = uri.split('/').next_back().unwrap_or("1"); let backend = app.state.db_backend; let sql = adapt_sql( "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) .bind(uri) .bind(did) .bind(collection) .bind(rkey) .bind(serde_json::to_string(record).unwrap_or_default()) .bind("bafytest") .bind(now_rfc3339()) .execute(&app.state.db) .await .unwrap();}
// ---------------------------------------------------------------------------// Profile// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn profile_no_auth_returns_401() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/xrpc/app.bsky.actor.getProfile") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);}
// ---------------------------------------------------------------------------// Catch-all GET (queries)// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn xrpc_get_unknown_method_proxies_and_returns_bad_gateway() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/xrpc/nonexistent.method") .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) .await .unwrap();
// Unknown methods are proxied to the resolved authority; DNS lookup // failure for a nonsense NSID results in a 502 Bad Gateway. assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);}
#[tokio::test]#[serial]async fn xrpc_get_non_query_returns_400() { common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await;
// game is a record, not a query let resp = app .router .clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.game") .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]async fn xrpc_get_single_record_by_uri() { common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await;
let did = "did:plc:test"; let uri = "at://did:plc:test/games.gamesgamesgamesgames.game/abc123"; let record = json!({"title": "Test Game", "$type": "games.gamesgamesgamesgames.game"}); seed_record(&app, uri, did, "games.gamesgamesgamesgames.game", &record).await;
// Mock PLC for PDS resolution Mock::given(method("GET")) .and(path(format!("/{did}"))) .respond_with( ResponseTemplate::new(200) .set_body_json(fixtures::did_document(did, "https://pds.example.com")), ) .mount(&app.mock_server) .await;
let resp = app .router .clone() .oneshot( Request::builder() .uri(format!( "/xrpc/games.gamesgamesgamesgames.listGames?uri={}", urlencoding::encode(uri) )) .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["record"]["title"], "Test Game"); assert_eq!(json["record"]["uri"], uri);}
#[tokio::test]#[serial]async fn xrpc_get_record_not_found() { common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await;
let resp = app .router.clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?uri=at%3A%2F%2Fdid%3Aplc%3Anone%2Fgames.gamesgamesgamesgames.game%2Fmissing") .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);}
#[tokio::test]#[serial]async fn xrpc_get_list_with_pagination() { common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await;
let did = "did:plc:test";
// Mock PLC for enrichment Mock::given(method("GET")) .and(path_regex("/did:plc:.*")) .respond_with( ResponseTemplate::new(200) .set_body_json(fixtures::did_document(did, "https://pds.example.com")), ) .mount(&app.mock_server) .await;
// Seed 3 records for i in 1..=3 { let uri = format!("at://{did}/games.gamesgamesgamesgames.game/rec{i}"); seed_record( &app, &uri, did, "games.gamesgamesgamesgames.game", &json!({"title": format!("Game {i}")}), ) .await; }
// Request with limit=2 let resp = app .router .clone() .clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?limit=2") .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["records"].as_array().unwrap().len(), 2); assert!(json.get("cursor").is_some());
// Use cursor for next page let cursor = json["cursor"].as_str().unwrap(); let resp = app .router .clone() .oneshot( Request::builder() .uri(format!( "/xrpc/games.gamesgamesgamesgames.listGames?limit=2&cursor={cursor}" )) .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["records"].as_array().unwrap().len(), 1); assert!(json.get("cursor").is_none());}
#[tokio::test]#[serial]async fn xrpc_get_list_filtered_by_did() { common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await;
// Mock PLC Mock::given(method("GET")) .and(path_regex("/did:plc:.*")) .respond_with( ResponseTemplate::new(200).set_body_json(fixtures::did_document( "did:plc:a", "https://pds.example.com", )), ) .mount(&app.mock_server) .await;
// Seed records for two different DIDs seed_record( &app, "at://did:plc:a/games.gamesgamesgamesgames.game/1", "did:plc:a", "games.gamesgamesgamesgames.game", &json!({"title": "Game A"}), ) .await; seed_record( &app, "at://did:plc:b/games.gamesgamesgamesgames.game/2", "did:plc:b", "games.gamesgamesgamesgames.game", &json!({"title": "Game B"}), ) .await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?did=did:plc:a") .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; let records = json["records"].as_array().unwrap(); assert_eq!(records.len(), 1); assert_eq!(records[0]["title"], "Game A");}
// ---------------------------------------------------------------------------// Catch-all POST (procedures)// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn xrpc_post_no_auth_returns_401() { common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await;
let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/xrpc/games.gamesgamesgamesgames.createGame") .header("content-type", "application/json") .body(Body::from(b"{}".to_vec())) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);}
#[tokio::test]#[serial]async fn xrpc_delete_procedure_removes_record() { common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; let backend = app.state.db_backend;
// Upload delete procedure lexicon with action: "delete" let resp = app .router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::delete_game_procedure_lexicon(), "target_collection": "games.gamesgamesgamesgames.game", "action": "delete" }), )) .await .unwrap(); assert!(resp.status().is_success());
// Seed a record directly let did = "did:plc:test"; let uri = "at://did:plc:test/games.gamesgamesgamesgames.game/del1"; let record = json!({"title": "To Delete", "$type": "games.gamesgamesgamesgames.game"}); seed_record(&app, uri, did, "games.gamesgamesgamesgames.game", &record).await;
// Verify record exists let sql = adapt_sql("SELECT COUNT(*) FROM records WHERE uri = ?", backend); let count: (i64,) = sqlx::query_as(&sql) .bind(uri) .fetch_one(&app.state.db) .await .unwrap(); assert_eq!(count.0, 1);
// Use cookie auth for the procedure call let (cookie_name, cookie_val) = common::auth::admin_cookie_header(did, &app.state.cookie_key);
// Mock PLC directory for PDS resolution Mock::given(method("GET")) .and(path(format!("/{did}"))) .respond_with( ResponseTemplate::new(200) .set_body_json(fixtures::did_document(did, &app.mock_server.uri())), ) .mount(&app.mock_server) .await;
// Mock PDS deleteRecord Mock::given(method("POST")) .and(path("/xrpc/com.atproto.repo.deleteRecord")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) .mount(&app.mock_server) .await;
// Mock the DPoP token exchange endpoint Mock::given(method("POST")) .and(path("/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "access_token": "test-dpop-token", "token_type": "DPoP", "expires_in": 3600 }))) .mount(&app.mock_server) .await;
// Call the delete procedure — the PDS call may fail due to DPoP/session // setup in tests, but we verify the lexicon action was stored correctly. let _resp = app .router .clone() .clone() .oneshot( Request::builder() .method("POST") .uri("/xrpc/games.gamesgamesgamesgames.deleteGame") .header(cookie_name, cookie_val) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"uri": uri})).unwrap(), )) .unwrap(), ) .await .unwrap();
// Verify the lexicon was uploaded with the correct action let lexicon = app .state .lexicons .get("games.gamesgamesgamesgames.deleteGame") .await .unwrap(); assert_eq!(lexicon.action, happyview::lexicon::ProcedureAction::Delete);}
#[tokio::test]#[serial]async fn upload_lexicon_with_invalid_action_returns_400() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::create_game_procedure_lexicon(), "target_collection": "games.gamesgamesgamesgames.game", "action": "invalid" }), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}