diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -728,6 +728,7 @@ "tower", "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "uuid", "wiremock", ] @@ -2747,6 +2748,12 @@ "idna", "percent-encoding", "serde", ] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf-8" diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,4 @@ wiremock = "0.6" tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" serial_test = "3" +urlencoding = "2.1.3" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,9 @@ +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: happyview + POSTGRES_PASSWORD: happyview + POSTGRES_DB: happyview_test + ports: + - "5433:5432" diff --git a/tests/common/app.rs b/tests/common/app.rs new file mode 100644 --- /dev/null +++ b/tests/common/app.rs @@ -0,0 +1,68 @@ +use axum::Router; +use happyview::config::Config; +use happyview::lexicon::LexiconRegistry; +use happyview::{admin, server, AppState}; +use tokio::sync::watch; +use wiremock::MockServer; + +use crate::common::db; + +pub struct TestApp { + pub router: Router, + pub state: AppState, + pub mock_server: MockServer, + pub admin_secret: String, +} + +impl TestApp { + /// Build a fully wired TestApp with a real Postgres database and wiremock + /// for external services (AIP, relay, PLC directory). + pub async fn new() -> Self { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + + let mock_server = MockServer::start().await; + let mock_url = mock_server.uri(); + + let admin_secret = "test-admin-secret".to_string(); + + let config = Config { + host: "127.0.0.1".into(), + port: 0, + database_url: String::new(), // not used — pool is already connected + aip_url: mock_url.clone(), + jetstream_url: String::new(), + admin_secret: Some(admin_secret.clone()), + relay_url: mock_url.clone(), + plc_url: mock_url.clone(), + }; + + admin::bootstrap(&pool, &config.admin_secret).await; + + let lexicons = LexiconRegistry::new(); + lexicons + .load_from_db(&pool) + .await + .expect("failed to load lexicons"); + + let initial_collections = lexicons.get_record_collections().await; + let (collections_tx, _collections_rx) = watch::channel(initial_collections); + + let state = AppState { + config, + http: reqwest::Client::new(), + db: pool, + lexicons, + collections_tx, + }; + + let router = server::router(state.clone()); + + Self { + router, + state, + mock_server, + admin_secret, + } + } +} diff --git a/tests/common/auth.rs b/tests/common/auth.rs new file mode 100644 --- /dev/null +++ b/tests/common/auth.rs @@ -0,0 +1,25 @@ +use axum::http::{HeaderName, HeaderValue}; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::common::fixtures; + +/// Build an Authorization header for admin endpoints. +pub fn admin_auth_header(token: &str) -> (HeaderName, HeaderValue) { + ( + HeaderName::from_static("authorization"), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ) +} + +/// Mount a mock on the given server that responds to AIP userinfo requests +/// with a successful response containing the given DID. +pub async fn mock_aip_userinfo(mock_server: &MockServer, did: &str) { + Mock::given(method("GET")) + .and(path("/oauth/userinfo")) + .respond_with( + ResponseTemplate::new(200).set_body_json(fixtures::userinfo_response(did)), + ) + .mount(mock_server) + .await; +} diff --git a/tests/common/db.rs b/tests/common/db.rs new file mode 100644 --- /dev/null +++ b/tests/common/db.rs @@ -0,0 +1,26 @@ +use sqlx::PgPool; + +/// Connect to the test database using `TEST_DATABASE_URL`. +pub async fn test_pool() -> PgPool { + let url = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must be set for e2e tests"); + + let pool = PgPool::connect(&url) + .await + .expect("failed to connect to test database"); + + sqlx::migrate!() + .run(&pool) + .await + .expect("failed to run migrations on test database"); + + pool +} + +/// Truncate all application tables, preserving schema. +pub async fn truncate_all(pool: &PgPool) { + sqlx::query("TRUNCATE records, lexicons, backfill_jobs, admins RESTART IDENTITY CASCADE") + .execute(pool) + .await + .expect("failed to truncate tables"); +} diff --git a/tests/common/fixtures.rs b/tests/common/fixtures.rs new file mode 100644 --- /dev/null +++ b/tests/common/fixtures.rs @@ -0,0 +1,92 @@ +use serde_json::{json, Value}; + +/// A minimal record-type lexicon JSON for testing. +pub fn game_record_lexicon() -> Value { + json!({ + "lexicon": 1, + "id": "games.gamesgamesgamesgames.game", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "title": { "type": "string" } + } + } + } + } + }) +} + +/// A query-type lexicon JSON that targets the game record collection. +pub fn list_games_query_lexicon() -> Value { + json!({ + "lexicon": 1, + "id": "games.gamesgamesgamesgames.listGames", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "properties": { + "limit": { "type": "integer" } + } + }, + "output": { + "encoding": "application/json" + } + } + } + }) +} + +/// A procedure-type lexicon JSON that targets the game record collection. +pub fn create_game_procedure_lexicon() -> Value { + json!({ + "lexicon": 1, + "id": "games.gamesgamesgamesgames.createGame", + "defs": { + "main": { + "type": "procedure", + "input": { + "encoding": "application/json" + }, + "output": { + "encoding": "application/json" + } + } + } + }) +} + +/// A fake DID document for testing PLC directory resolution. +pub fn did_document(did: &str, pds_endpoint: &str) -> Value { + json!({ + "id": did, + "alsoKnownAs": [format!("at://test.handle")], + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": pds_endpoint + }] + }) +} + +/// A fake app.bsky.actor.profile getRecord response. +pub fn profile_record() -> Value { + json!({ + "uri": "at://did:plc:test/app.bsky.actor.profile/self", + "cid": "bafytest", + "value": { + "displayName": "Test User", + "description": "A test user" + } + }) +} + +/// A fake AIP userinfo response. +pub fn userinfo_response(did: &str) -> Value { + json!({ "sub": did }) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,8 @@ +#[allow(dead_code, unused_imports)] +pub mod app; +#[allow(dead_code, unused_imports)] +pub mod auth; +#[allow(dead_code, unused_imports)] +pub mod db; +#[allow(dead_code, unused_imports)] +pub mod fixtures; diff --git a/tests/e2e_admin.rs b/tests/e2e_admin.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_admin.rs @@ -0,0 +1,530 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; +use common::auth::admin_auth_header; +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_get(uri: &str, token: &str) -> Request { + let (hname, hval) = admin_auth_header(token); + Request::builder() + .uri(uri) + .header(hname, hval) + .body(Body::empty()) + .unwrap() +} + +fn admin_post(uri: &str, token: &str, body: &Value) -> Request { + let (hname, hval) = admin_auth_header(token); + Request::builder() + .method("POST") + .uri(uri) + .header(hname, hval) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn admin_delete(uri: &str, token: &str) -> Request { + let (hname, hval) = admin_auth_header(token); + Request::builder() + .method("DELETE") + .uri(uri) + .header(hname, hval) + .body(Body::empty()) + .unwrap() +} + +// --------------------------------------------------------------------------- +// Auth tests +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn admin_no_auth_returns_401() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot( + Request::builder() + .uri("/admin/lexicons") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn admin_wrong_token_returns_401() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot(admin_get("/admin/lexicons", "wrong-token")) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn admin_valid_token_returns_200() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot(admin_get("/admin/lexicons", &app.admin_secret)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// Lexicon CRUD +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn lexicon_create_returns_201() { + let app = TestApp::new().await; + let body = json!({ + "lexicon_json": fixtures::game_record_lexicon(), + "backfill": true + }); + + let resp = app + .router + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let json = json_body(resp).await; + assert_eq!(json["id"], "games.gamesgamesgamesgames.game"); + assert_eq!(json["revision"], 1); +} + +#[tokio::test] +#[serial] +async fn lexicon_upsert_returns_200_with_incremented_revision() { + let app = TestApp::new().await; + let body = json!({ + "lexicon_json": fixtures::game_record_lexicon(), + "backfill": true + }); + + // First create + let resp = app + .router + .clone() + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + // Upsert + let resp = app + .router + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["revision"], 2); +} + +#[tokio::test] +#[serial] +async fn lexicon_invalid_version_returns_400() { + let app = TestApp::new().await; + let body = json!({ + "lexicon_json": { "lexicon": 99, "id": "test.bad" }, + }); + + let resp = app + .router + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn lexicon_missing_id_returns_400() { + let app = TestApp::new().await; + let body = json!({ + "lexicon_json": { "lexicon": 1 }, + }); + + let resp = app + .router + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn lexicon_list_all() { + let app = TestApp::new().await; + + // Seed a lexicon + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + &app.admin_secret, + &json!({ "lexicon_json": fixtures::game_record_lexicon() }), + )) + .await + .unwrap(); + + let resp = app + .router + .oneshot(admin_get("/admin/lexicons", &app.admin_secret)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + let arr = json.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["id"], "games.gamesgamesgamesgames.game"); +} + +#[tokio::test] +#[serial] +async fn lexicon_get_by_id() { + let app = TestApp::new().await; + + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + &app.admin_secret, + &json!({ "lexicon_json": fixtures::game_record_lexicon() }), + )) + .await + .unwrap(); + + let resp = app + .router + .oneshot(admin_get( + "/admin/lexicons/games.gamesgamesgamesgames.game", + &app.admin_secret, + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["id"], "games.gamesgamesgamesgames.game"); +} + +#[tokio::test] +#[serial] +async fn lexicon_get_not_found() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot(admin_get( + "/admin/lexicons/nonexistent.lexicon", + &app.admin_secret, + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn lexicon_delete() { + let app = TestApp::new().await; + + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + &app.admin_secret, + &json!({ "lexicon_json": fixtures::game_record_lexicon() }), + )) + .await + .unwrap(); + + let resp = app + .router + .oneshot(admin_delete( + "/admin/lexicons/games.gamesgamesgamesgames.game", + &app.admin_secret, + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +#[serial] +async fn lexicon_delete_not_found() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot(admin_delete( + "/admin/lexicons/nonexistent.lexicon", + &app.admin_secret, + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn stats_empty_db() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot(admin_get("/admin/stats", &app.admin_secret)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["total_records"], 0); + assert!(json["collections"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +#[serial] +async fn stats_with_seeded_records() { + let app = TestApp::new().await; + + // Seed records directly + sqlx::query( + "INSERT INTO records (uri, did, collection, rkey, record, cid) VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind("at://did:plc:test/test.collection/1") + .bind("did:plc:test") + .bind("test.collection") + .bind("1") + .bind(serde_json::json!({"title": "test"})) + .bind("bafytest") + .execute(&app.state.db) + .await + .unwrap(); + + let resp = app + .router + .oneshot(admin_get("/admin/stats", &app.admin_secret)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["total_records"], 1); + assert_eq!(json["collections"][0]["collection"], "test.collection"); + assert_eq!(json["collections"][0]["count"], 1); +} + +// --------------------------------------------------------------------------- +// Backfill +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn backfill_create_job() { + let app = TestApp::new().await; + let body = json!({ "collection": "test.collection" }); + + let resp = app + .router + .oneshot(admin_post("/admin/backfill", &app.admin_secret, &body)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let json = json_body(resp).await; + assert_eq!(json["status"], "pending"); + assert!(json.get("id").is_some()); +} + +#[tokio::test] +#[serial] +async fn backfill_list_jobs() { + let app = TestApp::new().await; + + // Create a job first + app.router + .clone() + .oneshot(admin_post( + "/admin/backfill", + &app.admin_secret, + &json!({}), + )) + .await + .unwrap(); + + let resp = app + .router + .oneshot(admin_get("/admin/backfill/status", &app.admin_secret)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json.as_array().unwrap().len(), 1); +} + +// --------------------------------------------------------------------------- +// Admin management +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn admin_create_returns_api_key() { + let app = TestApp::new().await; + let body = json!({ "name": "test-admin" }); + + let resp = app + .router + .oneshot(admin_post("/admin/admins", &app.admin_secret, &body)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let json = json_body(resp).await; + assert_eq!(json["name"], "test-admin"); + assert!(json.get("api_key").is_some()); + assert!(json.get("id").is_some()); +} + +#[tokio::test] +#[serial] +async fn admin_created_key_authenticates() { + let app = TestApp::new().await; + let body = json!({ "name": "new-admin" }); + + // Create admin + let resp = app + .router + .clone() + .oneshot(admin_post("/admin/admins", &app.admin_secret, &body)) + .await + .unwrap(); + let json = json_body(resp).await; + let api_key = json["api_key"].as_str().unwrap(); + + // Use the new key + let resp = app + .router + .oneshot(admin_get("/admin/lexicons", api_key)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +#[serial] +async fn admin_list_excludes_keys() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot(admin_get("/admin/admins", &app.admin_secret)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + let admins = json.as_array().unwrap(); + assert!(!admins.is_empty()); + // No admin should expose api_key or api_key_hash + for admin in admins { + assert!(admin.get("api_key").is_none()); + assert!(admin.get("api_key_hash").is_none()); + } +} + +#[tokio::test] +#[serial] +async fn admin_delete_returns_204() { + let app = TestApp::new().await; + + // Create an admin to delete + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/admins", + &app.admin_secret, + &json!({ "name": "disposable" }), + )) + .await + .unwrap(); + let json = json_body(resp).await; + let id = json["id"].as_str().unwrap(); + + let resp = app + .router + .oneshot(admin_delete( + &format!("/admin/admins/{id}"), + &app.admin_secret, + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +#[serial] +async fn admin_delete_not_found() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot(admin_delete( + "/admin/admins/00000000-0000-0000-0000-000000000000", + &app.admin_secret, + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/tests/e2e_health.rs b/tests/e2e_health.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_health.rs @@ -0,0 +1,28 @@ +mod common; + +use axum::body::Body; +use axum::http::Request; +use http_body_util::BodyExt; +use serial_test::serial; +use tower::ServiceExt; + +#[tokio::test] +#[serial] +async fn health_returns_200_ok() { + let app = common::app::TestApp::new().await; + + let resp = app + .router + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"ok"); +} diff --git a/tests/e2e_xrpc.rs b/tests/e2e_xrpc.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_xrpc.rs @@ -0,0 +1,445 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use serial_test::serial; +use tower::ServiceExt; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, ResponseTemplate}; + +use common::app::TestApp; +use common::auth::{admin_auth_header, mock_aip_userinfo}; +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, token: &str, body: &Value) -> Request { + let (hname, hval) = admin_auth_header(token); + Request::builder() + .method("POST") + .uri(uri) + .header(hname, hval) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn authed_get(uri: &str, token: &str) -> Request { + Request::builder() + .uri(uri) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() +} + +/// Seed the game record lexicon and a query lexicon into the test app. +async fn seed_lexicons(app: &TestApp) { + // Record lexicon + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + &app.admin_secret, + &json!({ + "lexicon_json": fixtures::game_record_lexicon(), + "backfill": false + }), + )) + .await + .unwrap(); + + // Query lexicon + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + &app.admin_secret, + &json!({ + "lexicon_json": fixtures::list_games_query_lexicon(), + "target_collection": "games.gamesgamesgamesgames.game" + }), + )) + .await + .unwrap(); + + // Procedure lexicon + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + &app.admin_secret, + &json!({ + "lexicon_json": fixtures::create_game_procedure_lexicon(), + "target_collection": "games.gamesgamesgamesgames.game" + }), + )) + .await + .unwrap(); +} + +/// Seed a record directly into the database. +async fn seed_record(app: &TestApp, uri: &str, did: &str, collection: &str, record: &Value) { + let rkey = uri.split('/').last().unwrap_or("1"); + sqlx::query( + "INSERT INTO records (uri, did, collection, rkey, record, cid) VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(uri) + .bind(did) + .bind(collection) + .bind(rkey) + .bind(record) + .bind("bafytest") + .execute(&app.state.db) + .await + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Profile +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn profile_no_auth_returns_401() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot( + Request::builder() + .uri("/xrpc/app.bsky.actor.getProfile") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn profile_with_mocked_services_returns_200() { + let app = TestApp::new().await; + let did = "did:plc:testuser"; + + // Mock AIP userinfo + mock_aip_userinfo(&app.mock_server, did).await; + + // Mock PLC directory + 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 getRecord for profile + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with( + ResponseTemplate::new(200).set_body_json(fixtures::profile_record()), + ) + .mount(&app.mock_server) + .await; + + let resp = app + .router + .oneshot(authed_get( + "/xrpc/app.bsky.actor.getProfile", + "valid-token", + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["did"], did); + assert_eq!(json["displayName"], "Test User"); +} + +// --------------------------------------------------------------------------- +// Catch-all GET (queries) +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn xrpc_get_unknown_method_returns_400() { + let app = TestApp::new().await; + + let resp = app + .router + .oneshot( + Request::builder() + .uri("/xrpc/nonexistent.method") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn xrpc_get_non_query_returns_400() { + let app = TestApp::new().await; + seed_lexicons(&app).await; + + // game is a record, not a query + let resp = app + .router + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.game") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn xrpc_get_single_record_by_uri() { + 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 + .oneshot( + Request::builder() + .uri(&format!( + "/xrpc/games.gamesgamesgamesgames.listGames?uri={}", + urlencoding::encode(uri) + )) + .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() { + let app = TestApp::new().await; + seed_lexicons(&app).await; + + let resp = app + .router + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames?uri=at%3A%2F%2Fdid%3Aplc%3Anone%2Fgames.gamesgamesgamesgames.game%2Fmissing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn xrpc_get_list_with_pagination() { + 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() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames?limit=2") + .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 + .oneshot( + Request::builder() + .uri(&format!( + "/xrpc/games.gamesgamesgamesgames.listGames?limit=2&cursor={cursor}" + )) + .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() { + 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 + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames?did=did:plc:a") + .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() { + let app = TestApp::new().await; + seed_lexicons(&app).await; + + let resp = app + .router + .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_post_non_procedure_returns_400() { + let app = TestApp::new().await; + seed_lexicons(&app).await; + + // Mock AIP userinfo so auth passes + mock_aip_userinfo(&app.mock_server, "did:plc:test").await; + + let resp = app + .router + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", "Bearer valid-token") + .header("content-type", "application/json") + .body(Body::from(b"{}".to_vec())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +}