Files
movies-diary/crates/bootstrap/tests/bootstrap_test.rs

303 lines
10 KiB
Rust

//! The composition root assembles on a fresh database, with every adapter real.
mod support;
#[tokio::test]
async fn server_builds_on_a_fresh_database() {
let pool = support::memory_pool().await;
let router = bootstrap::build_server_on(&support::test_config(), pool)
.await
.expect("server builds");
let (status, _, _) = support::send(&router, support::get("/health", None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn worker_builds_on_a_fresh_database() {
let pool = support::memory_pool().await;
bootstrap::build_worker_on(&support::test_config(), pool)
.await
.expect("worker builds");
}
/// Without the federation feature, following another local user still works
/// (the request lands as pending for the target):
/// the local social ports are wired in place of the ActivityPub ones.
#[cfg(not(feature = "federation"))]
#[tokio::test]
async fn federation_off_build_wires_local_social() {
use axum::http::Method;
let (router, _pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let bob = support::register(&router, "bob", "bob@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
Method::POST,
"/api/v1/social/follow",
Some(&alice.token),
serde_json::json!({ "handle": "bob@localhost:3000" }),
),
)
.await;
assert_eq!(status, 200, "follow: {body}");
let (status, _, body) = support::send(
&router,
axum::http::Request::builder()
.uri("/api/v1/social/followers/pending")
.header("authorization", format!("Bearer {}", bob.token))
.body(axum::body::Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, 200, "pending followers: {body}");
let followers: serde_json::Value = serde_json::from_str(&body).unwrap();
let actors = followers["actors"].as_array().unwrap();
assert_eq!(
actors.len(),
1,
"bob should have one pending follower: {body}"
);
assert!(
body.contains(&alice.user_id) || body.contains("alice"),
"follower should be alice: {body}"
);
}
/// The server never talks to the bus: whatever `EVENT_BUS_BACKEND` says, a
/// change and its event land in the outbox table. No NATS server is reachable here.
#[tokio::test]
async fn server_publishes_to_the_outbox_even_when_bus_is_nats() {
let pool = support::memory_pool().await;
let mut config = support::test_config();
config.events.bus_backend = "nats".into();
config.nats.url = Some("nats://127.0.0.1:1".into());
let router = bootstrap::build_server_on(&config, pool.clone())
.await
.expect("server builds without a reachable NATS");
let session = support::register(&router, "alice", "alice@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
axum::http::Method::POST,
"/api/v1/reviews",
Some(&session.token),
serde_json::json!({
"manual_title": "Outbox",
"manual_release_year": 2001,
"rating": 4,
"comment": "queued",
"watched_at": "2024-01-01"
}),
),
)
.await;
assert_eq!(status, 201, "log review: {body}");
let queued: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'ReviewLogged' AND event_id IS NOT NULL",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(queued, 1);
}
/// Handler names key the processed-event ledger, so each must be unique. The
/// check runs over the real wiring, in both federation builds.
#[tokio::test]
async fn handler_names_are_unique() {
let pool = support::memory_pool().await;
let worker = bootstrap::build_worker_on(&support::test_config(), pool)
.await
.expect("worker builds");
let names = worker.handler_names();
let unique: std::collections::HashSet<_> = names.iter().collect();
assert_eq!(
unique.len(),
names.len(),
"duplicate handler name in {names:?}"
);
let mut sorted = names.clone();
sorted.sort_unstable();
// Names key the ledger: a rename re-runs events inside the retention
// window, so changing this list must be a deliberate edit.
let mut expected = vec![
"account_purge",
"image_cleanup",
"movie_discovery_indexer",
"movie_enrichment",
"person_enrichment",
"poster_sync",
"search_cleanup",
"search_reindex",
"wrapup",
];
if cfg!(feature = "federation") {
expected.extend(["activitypub", "follow_backfill"]);
}
expected.sort_unstable();
// Image conversion ("image_conversion") is disabled in the test config.
assert_eq!(sorted, expected);
}
async fn queued(pool: &sqlx::SqlitePool, event_type: &str) -> i64 {
sqlx::query_scalar(
"SELECT COUNT(*) FROM event_queue WHERE event_type = ? AND event_id IS NOT NULL",
)
.bind(event_type)
.fetch_one(pool)
.await
.unwrap()
}
/// A local follow writes both follow rows and its event in one commit, in
/// both federation builds.
#[tokio::test]
async fn a_local_follow_lands_with_its_event_in_the_outbox() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let _bob = support::register(&router, "bob", "bob@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
axum::http::Method::POST,
"/api/v1/social/follow",
Some(&alice.token),
serde_json::json!({ "handle": "bob@localhost:3000" }),
),
)
.await;
assert_eq!(status, 200, "follow: {body}");
let following: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_following")
.fetch_one(&pool)
.await
.unwrap();
let followers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_followers")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!((following, followers), (1, 1));
assert_eq!(queued(&pool, "FollowRequested").await, 1);
}
/// The k_ap path owns its transaction, so the adapter publishes the event
/// itself once the k_ap write has succeeded.
#[cfg(feature = "federation")]
#[tokio::test]
async fn a_remote_block_publishes_its_event_after_the_k_ap_write() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let (status, _, body) = support::send(
&router,
support::json_request(
axum::http::Method::POST,
"/api/v1/social/block",
Some(&alice.token),
serde_json::json!({ "actor_url": "https://remote.example/users/ann" }),
),
)
.await;
assert_eq!(status, 204, "block: {body}");
let blocked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(blocked, 1);
assert_eq!(queued(&pool, "ActorBlocked").await, 1);
}
/// A token outlives the account that owns it, so every authenticated route
/// must re-check the account: a deleted user gets exactly what an anonymous
/// caller gets.
#[tokio::test]
async fn a_deleted_users_token_is_rejected() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let bearer = |token: &str| {
axum::http::Request::builder()
.uri("/api/v1/diary/export?format=json")
.header("authorization", format!("Bearer {token}"))
.body(axum::body::Body::empty())
.unwrap()
};
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
assert_eq!(status, 200, "token works while the account is live: {body}");
let (status, _, _) =
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
assert_eq!(status, 200, "cookie works while the account is live");
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
assert_eq!(status, 401, "json route: {body}");
let (anon_status, anon_headers, _) =
support::send(&router, support::get("/diary/export", None)).await;
let (status, headers, _) =
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
assert_eq!(status, anon_status, "classic owner page");
assert_eq!(headers.get("location"), anon_headers.get("location"));
assert_eq!(headers.get("location").unwrap(), "/login");
}
/// A deleted account stays resolvable inside k-ap (to sign its `Delete`), but
/// every ActivityPub document for it answers 410, while the classic profile
/// page stays 404.
#[cfg(feature = "federation")]
#[tokio::test]
async fn actor_document_for_a_tombstone_is_gone() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let ap_get = |path: String| {
axum::http::Request::builder()
.uri(path)
.header("accept", "application/activity+json")
.body(axum::body::Body::empty())
.unwrap()
};
let paths = |id: &str| {
[
format!("/users/{id}"),
format!("/users/{id}/followers"),
format!("/users/{id}/following"),
format!("/users/{id}/outbox"),
format!("/users/{id}/featured"),
]
};
let (status, _, body) =
support::send(&router, ap_get(format!("/users/{}", alice.user_id))).await;
assert_eq!(status, 200, "live actor: {body}");
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
for path in paths(&alice.user_id) {
let (status, _, body) = support::send(&router, ap_get(path.clone())).await;
assert_eq!(status, 410, "{path}: {body}");
}
let (status, _, _) = support::send(
&router,
support::get(&format!("/users/{}", alice.user_id), None),
)
.await;
assert_eq!(status, 404, "the classic profile page stays 404");
}