···11+use axum::Router;
22+use happyview::config::Config;
33+use happyview::lexicon::LexiconRegistry;
44+use happyview::{admin, server, AppState};
55+use tokio::sync::watch;
66+use wiremock::MockServer;
77+88+use crate::common::db;
99+1010+pub struct TestApp {
1111+ pub router: Router,
1212+ pub state: AppState,
1313+ pub mock_server: MockServer,
1414+ pub admin_secret: String,
1515+}
1616+1717+impl TestApp {
1818+ /// Build a fully wired TestApp with a real Postgres database and wiremock
1919+ /// for external services (AIP, relay, PLC directory).
2020+ pub async fn new() -> Self {
2121+ let pool = db::test_pool().await;
2222+ db::truncate_all(&pool).await;
2323+2424+ let mock_server = MockServer::start().await;
2525+ let mock_url = mock_server.uri();
2626+2727+ let admin_secret = "test-admin-secret".to_string();
2828+2929+ let config = Config {
3030+ host: "127.0.0.1".into(),
3131+ port: 0,
3232+ database_url: String::new(), // not used — pool is already connected
3333+ aip_url: mock_url.clone(),
3434+ jetstream_url: String::new(),
3535+ admin_secret: Some(admin_secret.clone()),
3636+ relay_url: mock_url.clone(),
3737+ plc_url: mock_url.clone(),
3838+ };
3939+4040+ admin::bootstrap(&pool, &config.admin_secret).await;
4141+4242+ let lexicons = LexiconRegistry::new();
4343+ lexicons
4444+ .load_from_db(&pool)
4545+ .await
4646+ .expect("failed to load lexicons");
4747+4848+ let initial_collections = lexicons.get_record_collections().await;
4949+ let (collections_tx, _collections_rx) = watch::channel(initial_collections);
5050+5151+ let state = AppState {
5252+ config,
5353+ http: reqwest::Client::new(),
5454+ db: pool,
5555+ lexicons,
5656+ collections_tx,
5757+ };
5858+5959+ let router = server::router(state.clone());
6060+6161+ Self {
6262+ router,
6363+ state,
6464+ mock_server,
6565+ admin_secret,
6666+ }
6767+ }
6868+}
+25
tests/common/auth.rs
···11+use axum::http::{HeaderName, HeaderValue};
22+use wiremock::matchers::{header, method, path};
33+use wiremock::{Mock, MockServer, ResponseTemplate};
44+55+use crate::common::fixtures;
66+77+/// Build an Authorization header for admin endpoints.
88+pub fn admin_auth_header(token: &str) -> (HeaderName, HeaderValue) {
99+ (
1010+ HeaderName::from_static("authorization"),
1111+ HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
1212+ )
1313+}
1414+1515+/// Mount a mock on the given server that responds to AIP userinfo requests
1616+/// with a successful response containing the given DID.
1717+pub async fn mock_aip_userinfo(mock_server: &MockServer, did: &str) {
1818+ Mock::given(method("GET"))
1919+ .and(path("/oauth/userinfo"))
2020+ .respond_with(
2121+ ResponseTemplate::new(200).set_body_json(fixtures::userinfo_response(did)),
2222+ )
2323+ .mount(mock_server)
2424+ .await;
2525+}
+26
tests/common/db.rs
···11+use sqlx::PgPool;
22+33+/// Connect to the test database using `TEST_DATABASE_URL`.
44+pub async fn test_pool() -> PgPool {
55+ let url = std::env::var("TEST_DATABASE_URL")
66+ .expect("TEST_DATABASE_URL must be set for e2e tests");
77+88+ let pool = PgPool::connect(&url)
99+ .await
1010+ .expect("failed to connect to test database");
1111+1212+ sqlx::migrate!()
1313+ .run(&pool)
1414+ .await
1515+ .expect("failed to run migrations on test database");
1616+1717+ pool
1818+}
1919+2020+/// Truncate all application tables, preserving schema.
2121+pub async fn truncate_all(pool: &PgPool) {
2222+ sqlx::query("TRUNCATE records, lexicons, backfill_jobs, admins RESTART IDENTITY CASCADE")
2323+ .execute(pool)
2424+ .await
2525+ .expect("failed to truncate tables");
2626+}
···11+#[allow(dead_code, unused_imports)]
22+pub mod app;
33+#[allow(dead_code, unused_imports)]
44+pub mod auth;
55+#[allow(dead_code, unused_imports)]
66+pub mod db;
77+#[allow(dead_code, unused_imports)]
88+pub mod fixtures;