code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

@@ -0,0 +1,404 @@
mod support;
use axum::{
body::Body,
http::{Method, Request, header},
};
use support::{PASSWORD, Session, get, json_request, send};
async fn promote(pool: &sqlx::SqlitePool, user_id: &str) {
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(user_id)
.execute(pool)
.await
.unwrap();
}
fn delete_json(uri: &str, token: &str, password: &str) -> Request<Body> {
json_request(
Method::DELETE,
uri,
Some(token),
serde_json::json!({ "password": password }),
)
}
fn classic_post(uri: &str, cookie: &str, password: &str) -> Request<Body> {
let csrf = cookie
.split(';')
.find_map(|c| c.trim().strip_prefix("csrf="))
.unwrap();
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.body(Body::from(format!("password={password}&_csrf={csrf}")))
.unwrap()
}
fn location(headers: &axum::http::HeaderMap) -> String {
headers
.get(header::LOCATION)
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default()
}
async fn two_users() -> (axum::Router, sqlx::SqlitePool, Session, Session) {
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;
(router, pool, alice, bob)
}
#[tokio::test]
async fn self_delete_with_a_wrong_password_is_refused_and_keeps_the_account() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, "nope"),
)
.await;
assert_eq!(status, 401, "{body}");
assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn self_delete_removes_the_account_and_revokes_the_token() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 204, "{body}");
let req = Request::builder()
.uri("/api/v1/profile")
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
.body(Body::empty())
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 401, "token of a deleted account");
for uri in [
format!("/users/{}", alice.user_id),
format!("/users/{}?embed=true", alice.user_id),
] {
let (status, _, _) = send(&router, get(&uri, None)).await;
assert_eq!(status, 404, "{uri}");
}
}
#[tokio::test]
async fn a_standard_user_cannot_delete_someone_else() {
let (router, _pool, alice, bob) = two_users().await;
let uri = format!("/api/v1/users/{}", bob.user_id);
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 403, "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn an_admin_deletes_another_account() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let uri = format!("/api/v1/users/{}", bob.user_id);
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, "nope")).await;
assert_eq!(status, 401, "wrong admin password");
assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}");
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 204, "{body}");
for uri in [
format!("/users/{}", bob.user_id),
format!("/users/{}?embed=true", bob.user_id),
] {
let (status, _, _) = send(&router, get(&uri, None)).await;
assert_eq!(status, 404, "{uri}");
}
let (status, _, _) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 404, "already deleted");
#[cfg(feature = "federation")]
{
let req = Request::builder()
.uri(format!("/users/{}", bob.user_id))
.header(header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 410, "actor endpoint of a deleted account");
}
}
#[tokio::test]
async fn the_last_admin_cannot_be_deleted() {
let (router, pool, alice, _bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 409, "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn classic_self_delete_logs_out_and_redirects_home() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, headers, _) = send(
&router,
classic_post("/settings/account/delete", &alice.cookie, "nope"),
)
.await;
assert_eq!(status, 303);
assert!(
location(&headers).starts_with("/settings/profile?error="),
"{headers:?}"
);
assert!(
support::cookie_value(&headers, "token").is_none(),
"a wrong password must not log out"
);
let (status, headers, _) = send(
&router,
classic_post("/settings/account/delete", &alice.cookie, PASSWORD),
)
.await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/");
assert_eq!(
support::cookie_value(&headers, "token").as_deref(),
Some("")
);
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn classic_self_delete_requires_the_csrf_token() {
let (router, _pool, alice, _bob) = two_users().await;
let req = Request::builder()
.method(Method::POST)
.uri("/settings/account/delete")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, &alice.cookie)
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 403);
}
#[tokio::test]
async fn classic_admin_delete_redirects_to_the_member_list() {
let (router, pool, alice, bob) = two_users().await;
let uri = format!("/users/{}/delete", bob.user_id);
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 403, "non-admin: {headers:?}");
promote(&pool, &alice.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/users");
assert!(support::cookie_value(&headers, "token").is_none());
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn classic_admin_delete_requires_the_csrf_token() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let req = Request::builder()
.method(Method::POST)
.uri(format!("/users/{}/delete", bob.user_id))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, &alice.cookie)
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 403);
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200, "target must survive a CSRF rejection");
}
#[tokio::test]
async fn a_non_admin_classic_delete_of_someone_else_is_forbidden() {
let (router, _pool, alice, bob) = two_users().await;
let uri = format!("/users/{}/delete", bob.user_id);
let (status, _, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 403);
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn classic_admin_self_delete_logs_out_unless_last_admin() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let uri = format!("/users/{}/delete", alice.user_id);
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert!(location(&headers).contains("error="), "{headers:?}");
assert!(support::cookie_value(&headers, "token").is_none());
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
promote(&pool, &bob.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/");
assert_eq!(
support::cookie_value(&headers, "token").as_deref(),
Some("")
);
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn delete_forms_render_only_where_they_apply() {
let (router, pool, alice, bob) = two_users().await;
let own_delete = "action=\"/settings/account/delete\"";
let user_delete = |id: &str| format!("action=\"/users/{id}/delete\"");
let (_, _, body) = send(&router, get("/settings/profile", Some(&alice.cookie))).await;
assert!(body.contains(own_delete), "owner settings page");
let bob_page = format!("/users/{}", bob.user_id);
let (_, _, body) = send(&router, get(&bob_page, None)).await;
assert!(!body.contains(&user_delete(&bob.user_id)), "anonymous");
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
assert!(!body.contains(&user_delete(&bob.user_id)), "non-admin");
promote(&pool, &alice.user_id).await;
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
assert!(body.contains(&user_delete(&bob.user_id)), "admin on other");
let (_, _, body) = send(
&router,
get(&format!("/users/{}", alice.user_id), Some(&alice.cookie)),
)
.await;
assert!(
!body.contains(&user_delete(&alice.user_id)),
"admin on self"
);
let (_, _, body) = send(
&router,
get(&format!("{bob_page}?embed=true"), Some(&alice.cookie)),
)
.await;
assert!(!body.contains("/delete"), "embed page");
}
const SERVICE_ACTOR: &str = "00000000-0000-4000-8000-000000000000";
#[tokio::test]
async fn the_service_actor_is_not_deletable_and_offers_no_delete_ui() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
promote(&pool, &bob.user_id).await;
let page = format!("/users/{SERVICE_ACTOR}");
let (status, _, body) = send(&router, get(&page, Some(&alice.cookie))).await;
assert_eq!(status, 200, "{body}");
assert!(
!body.contains(&format!("action=\"/users/{SERVICE_ACTOR}/delete\"")),
"admin sees a delete form for the service actor"
);
let uri = format!("/api/v1/users/{SERVICE_ACTOR}");
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 403, "{body}");
let req = Request::builder()
.uri(&uri)
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
.body(Body::empty())
.unwrap();
let (status, _, body) = send(&router, req).await;
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"is_service_account\":true"), "{body}");
let req = Request::builder()
.uri(format!("/api/v1/users/{}", bob.user_id))
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
.body(Body::empty())
.unwrap();
let (_, _, body) = send(&router, req).await;
assert!(!body.contains("is_service_account"), "{body}");
}
#[tokio::test]
async fn a_deleted_users_webhook_token_stops_ingesting_at_once() {
let (router, pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
json_request(
Method::POST,
"/api/v1/settings/webhook-tokens",
Some(&alice.token),
serde_json::json!({ "provider": "jellyfin", "label": null }),
),
)
.await;
assert_eq!(status, 200, "{body}");
let token = serde_json::from_str::<serde_json::Value>(&body).unwrap()["token"]
.as_str()
.unwrap()
.to_string();
let hook = |token: &str| {
Request::builder()
.method(Method::POST)
.uri("/api/v1/webhooks/jellyfin")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({
"NotificationType": "PlaybackStop", "ItemType": "Movie",
"Name": "Heat", "Year": 1995, "PlayedToCompletion": true,
"Provider_tmdb": "949"
})
.to_string(),
))
.unwrap()
};
let events = || async {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM watch_events")
.fetch_one(&pool)
.await
.unwrap()
};
let (status, _, body) = send(&router, hook(&token)).await;
assert_eq!(status, 200, "live token: {body}");
assert_eq!(events().await, 1);
let (status, _, _) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 204);
let (status, _, body) = send(&router, hook(&token)).await;
assert_eq!(status, 401, "{body}");
assert_eq!(events().await, 1, "no event ingested after deletion");
}

View File

@@ -0,0 +1,123 @@
mod support;
use std::time::Duration;
use domain::{events::DomainEvent, ports::UserRepository, value_objects::UserId};
async fn wait_for(pool: &sqlx::SqlitePool, sql: &str) -> bool {
for _ in 0..100 {
let n: i64 = sqlx::query_scalar(sql).fetch_one(pool).await.unwrap();
if n > 0 {
return true;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
#[tokio::test]
async fn the_worker_purges_a_deleted_account_and_frees_its_email() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
let users = sqlite::SqliteUserRepository::new(pool.clone());
users
.mark_deleted(
&id,
&[DomainEvent::UserDeleted {
user_id: id.clone(),
}],
)
.await
.unwrap();
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
.await
.unwrap();
let (stop, stopped) = tokio::sync::watch::channel(false);
let running = tokio::spawn(worker.run(stopped));
let purged = wait_for(
&pool,
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
)
.await;
stop.send(true).unwrap();
running.await.unwrap().unwrap();
assert!(purged, "the worker should purge the tombstoned account");
let email: String = sqlx::query_scalar("SELECT email FROM users WHERE username = 'alice'")
.fetch_one(&pool)
.await
.unwrap();
assert_ne!(email, "alice@example.com");
let again = support::register(&router, "alice2", "alice@example.com").await;
assert!(!again.user_id.is_empty(), "the email can register again");
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_are_told_before_the_purge_and_the_keypair_survives_it() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
for sql in [
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at)
VALUES ('http://127.0.0.1:1/users/ann', 'ann@127.0.0.1', 'http://127.0.0.1:1/inbox', 'now')",
] {
sqlx::query(sql).execute(&pool).await.unwrap();
}
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
VALUES (?, 'http://127.0.0.1:1/users/ann', 'accepted', 'now')",
)
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
let users = sqlite::SqliteUserRepository::new(pool.clone());
users
.mark_deleted(
&id,
&[DomainEvent::UserDeleted {
user_id: id.clone(),
}],
)
.await
.unwrap();
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
.await
.unwrap();
let (stop, stopped) = tokio::sync::watch::channel(false);
let running = tokio::spawn(worker.run(stopped));
let purged = wait_for(
&pool,
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
)
.await;
let enqueued = wait_for(
&pool,
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
)
.await;
stop.send(true).unwrap();
running.await.unwrap().unwrap();
assert!(purged);
assert!(enqueued, "the Delete should be enqueued for the follower");
let deliveries: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(deliveries, 1, "the Delete was enqueued for the follower");
let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_local_actors WHERE user_id = ?")
.bind(&alice.user_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(keys, 1, "the keypair stays until the tombstone cleanup");
}

View File

@@ -0,0 +1,386 @@
mod support;
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
use sqlx::SqlitePool;
use tower::ServiceExt;
use uuid::Uuid;
async fn test_app() -> Router {
support::server().await.0
}
async fn test_app_with_user() -> (Router, SqlitePool, Uuid, String) {
let (app, pool) = support::server().await;
let session = support::register(&app, "insights", "insights@example.com").await;
let user_id = session.user_id.parse().unwrap();
(app, pool, user_id, session.token)
}
/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo.
fn with_ip(req: Request<Body>) -> Request<Body> {
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
let mut req = req;
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
req
}
#[tokio::test]
async fn get_api_diary_returns_empty_list() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/diary")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["total_count"], 0);
assert_eq!(json["items"], serde_json::json!([]));
assert_eq!(json["limit"], 5);
assert_eq!(json["offset"], 0);
}
#[tokio::test]
async fn post_api_reviews_without_auth_returns_401() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.method("POST")
.uri("/api/v1/reviews")
.header("content-type", "application/json")
.body(Body::from(
r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#,
))
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn post_api_auth_login_unknown_user_returns_401() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from(r#"{"email":"a@b.com","password":"x"}"#))
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_api_movie_detail_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn tags_moviesdiary_redirects_to_home() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/tags/moviesdiary")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(response.headers().get("location").unwrap(), "/");
}
#[tokio::test]
async fn tags_other_redirects_to_search() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/tags/batman")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
response.headers().get("location").unwrap(),
"/?search=batman"
);
}
#[tokio::test]
async fn get_movie_detail_html_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
// ── Insights ─────────────────────────────────────────────────────────────────
async fn seed_insights_diary(pool: &SqlitePool, user_id: Uuid) {
// 12 thrillers rated 5 late at night, 12 comedies rated 2 — enough for a
// rating lift once genres come from movie_genres.
for i in 0..12u32 {
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Thriller {i}"))
.bind(2020)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(44400 + i as i64)
.bind("Thriller")
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(5)
.bind("2024-01-05 22:30:00")
.bind("2024-01-05 22:30:00")
.execute(pool)
.await
.unwrap();
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Comedy {i}"))
.bind(2015)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(55300 + i as i64)
.bind("Comedy")
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(2)
.bind("2024-03-10 13:00:00")
.bind("2024-03-10 13:00:00")
.execute(pool)
.await
.unwrap();
}
}
#[tokio::test]
async fn get_api_insights_returns_rating_lift_for_own_user() {
let (app, pool, user_id, token) = test_app_with_user().await;
seed_insights_diary(&pool, user_id).await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/insights")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
let (status, bytes) = (
response.status(),
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap(),
);
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8(bytes.to_vec()).unwrap()
);
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let rating_lifts: Vec<&serde_json::Value> = json["insights"]
.as_array()
.unwrap()
.iter()
.filter(|i| i["kind"] == "rating_lift")
.collect();
assert_eq!(rating_lifts.len(), 2, "thriller up, comedy down: {json}");
let thriller = rating_lifts
.iter()
.find(|i| i["genre"] == "Thriller")
.unwrap();
assert_eq!(thriller["delta"], 1.5);
assert_eq!(thriller["sample_count"], 12);
// Timestamps are real → rhythm fires. Hours split evenly between 22
// (thrillers) and 13 (comedies); every window covering either bucket ties,
// so the earliest one, 11:00–14:00, wins. Days tie too (Fri/Sun), sorted
// ascending.
let rhythm = json["insights"]
.as_array()
.unwrap()
.iter()
.find(|i| i["kind"] == "rhythm")
.expect("rhythm expected for real timestamps");
assert_eq!(rhythm["peak_hour_start"], 11);
assert_eq!(rhythm["peak_hour_end"], 14);
assert_eq!(rhythm["top_days"], serde_json::json!([5, 7]));
}
async fn get_insights_json(
app: Router,
token: &str,
query: &str,
) -> (StatusCode, serde_json::Value) {
let response = app
.oneshot(with_ip(
Request::builder()
.uri(format!("/api/v1/insights{query}"))
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}
#[tokio::test]
async fn get_api_insights_ignores_remote_reviews() {
let (app, pool, user_id, token) = test_app_with_user().await;
seed_insights_diary(&pool, user_id).await;
// Federated rows stored under the same user_id are someone else's watches.
for i in 0..12u32 {
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Horror {i}"))
.bind(2019)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(66600 + i as i64)
.bind("Horror")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(5)
.bind("2024-05-05 09:00:00")
.bind("2024-05-05 09:00:00")
.bind("https://remote.example/users/someone")
.execute(&pool)
.await
.unwrap();
}
let (status, json) = get_insights_json(app, &token, "").await;
assert_eq!(status, StatusCode::OK);
let insights = json["insights"].as_array().unwrap();
assert!(
!insights.iter().any(|i| i["genre"] == "Horror"),
"remote reviews must not feed insights: {json}"
);
let thriller = insights
.iter()
.find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller")
.unwrap();
assert_eq!(thriller["delta"], 1.5);
}
#[tokio::test]
async fn get_api_insights_buckets_rhythm_in_requested_time_zone() {
let (app, pool, user_id, token) = test_app_with_user().await;
seed_insights_diary(&pool, user_id).await;
// January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the
// earliest tied window moves from 11–14 to 12–15.
let (status, json) = get_insights_json(app, &token, "?tz=Europe/Warsaw").await;
assert_eq!(status, StatusCode::OK);
let rhythm = json["insights"]
.as_array()
.unwrap()
.iter()
.find(|i| i["kind"] == "rhythm")
.expect("rhythm expected");
assert_eq!(rhythm["peak_hour_start"], 12);
assert_eq!(rhythm["peak_hour_end"], 15);
}
#[tokio::test]
async fn get_api_insights_rejects_unknown_time_zone() {
let (app, _pool, _user_id, token) = test_app_with_user().await;
let (status, _) = get_insights_json(app, &token, "?tz=Mars/Olympus").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}

View File

@@ -0,0 +1,307 @@
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");
}
#[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}"
);
}
#[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);
}
#[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();
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();
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()
}
#[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);
}
#[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);
}
#[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");
}
#[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");
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn posting_to_a_tombstones_inbox_is_gone() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let post = |path: String| {
axum::http::Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/activity+json")
.body(axum::body::Body::from("{}"))
.unwrap()
};
let path = format!("/users/{}/inbox", alice.user_id);
let (live, _, _) = support::send(&router, post(path.clone())).await;
assert_ne!(live, 410, "a live user's inbox is not gone");
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, post(path)).await;
assert_eq!(status, 410, "{body}");
}

View File

@@ -0,0 +1,101 @@
mod support;
use axum::{
body::Body,
http::{Method, Request, header},
};
async fn server_with(config: config::AppConfig) -> axum::Router {
bootstrap::build_server_on(&config, support::memory_pool().await)
.await
.expect("server builds")
}
fn set_cookies(headers: &axum::http::HeaderMap) -> Vec<String> {
headers
.get_all(header::SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn registration_is_refused_when_allow_registration_is_false() {
let mut config = support::test_config();
config.server.allow_registration = false;
let router = server_with(config).await;
let form = Request::builder()
.method(Method::POST)
.uri("/register")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(format!(
"email=a%40example.com&username=alice&password={}&_csrf=x",
support::PASSWORD
)))
.unwrap();
let (status, headers, _) = support::send(&router, form).await;
assert_eq!(status, 303, "classic form redirects away");
assert_eq!(headers.get(header::LOCATION).unwrap(), "/");
assert!(
support::cookie_value(&headers, "token").is_none(),
"no session issued"
);
let (status, _, _) = support::send(
&router,
support::json_request(
Method::POST,
"/api/v1/auth/register",
None,
serde_json::json!({
"email": "a@example.com",
"username": "alice",
"password": support::PASSWORD,
}),
),
)
.await;
assert_eq!(status, 401, "JSON register is refused");
}
#[tokio::test]
async fn secure_cookies_setting_marks_cookies_secure() {
for secure in [true, false] {
let mut config = support::test_config();
config.server.secure_cookies = secure;
let router = server_with(config).await;
let (status, headers, _) = support::send(&router, support::get("/register", None)).await;
assert_eq!(status, 200);
let cookies = set_cookies(&headers);
assert!(!cookies.is_empty(), "csrf cookie is set");
assert!(
cookies.iter().all(|c| c.contains("Secure") == secure),
"secure_cookies={secure}: {cookies:?}"
);
let csrf = support::cookie_value(&headers, "csrf").expect("csrf cookie");
let form = Request::builder()
.method(Method::POST)
.uri("/register")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, format!("csrf={csrf}"))
.body(Body::from(format!(
"email=a%40example.com&username=alice&password={}&_csrf={csrf}",
support::PASSWORD
)))
.unwrap();
let (status, headers, _) = support::send(&router, form).await;
assert_eq!(status, 303);
let token_cookie = set_cookies(&headers)
.into_iter()
.find(|c| c.starts_with("token="))
.expect("session cookie");
assert_eq!(
token_cookie.contains("Secure"),
secure,
"session cookie, secure_cookies={secure}: {token_cookie}"
);
}
}

View File

@@ -0,0 +1,71 @@
DELETE /api/v1/admin/blocked-domains/{domain}
DELETE /api/v1/goals/{year}
DELETE /api/v1/import/profiles/{id}
DELETE /api/v1/reviews/{id}
DELETE /api/v1/settings/webhook-tokens/{id}
DELETE /api/v1/users/me
DELETE /api/v1/users/{id}
DELETE /api/v1/watchlist/{movie_id}
DELETE /api/v1/wrapups/{id}
GET /api/v1/activity-feed
GET /api/v1/admin/blocked-domains
GET /api/v1/diary
GET /api/v1/diary/export
GET /api/v1/goals
GET /api/v1/import/profiles
GET /api/v1/import/sessions/{id}
GET /api/v1/insights
GET /api/v1/movies
GET /api/v1/movies/{id}/history
GET /api/v1/movies/{id}/profile
GET /api/v1/movies/{movie_id}
GET /api/v1/people/{id}
GET /api/v1/people/{id}/credits
GET /api/v1/profile
GET /api/v1/search
GET /api/v1/settings
GET /api/v1/settings/webhook-tokens
GET /api/v1/social/blocked
GET /api/v1/social/followers
GET /api/v1/social/followers/pending
GET /api/v1/social/followers/pending/count
GET /api/v1/social/following
GET /api/v1/social/following/pending
GET /api/v1/social/relationship
GET /api/v1/users
GET /api/v1/users/{id}
GET /api/v1/users/{id}/goals
GET /api/v1/watch-queue
GET /api/v1/watchlist
GET /api/v1/watchlist/{movie_id}
GET /api/v1/wrapups
GET /api/v1/wrapups/{id}
GET /api/v1/wrapups/{id}/report
POST /api/v1/admin/blocked-domains
POST /api/v1/auth/login
POST /api/v1/auth/register
POST /api/v1/goals
POST /api/v1/import/profiles
POST /api/v1/import/sessions
POST /api/v1/import/sessions/{id}/confirm
POST /api/v1/movies/{id}/sync-poster
POST /api/v1/reviews
POST /api/v1/settings/webhook-tokens
POST /api/v1/social/block
POST /api/v1/social/follow
POST /api/v1/social/followers/accept
POST /api/v1/social/followers/reject
POST /api/v1/social/followers/remove
POST /api/v1/social/unblock
POST /api/v1/social/unfollow
POST /api/v1/watch-queue/confirm
POST /api/v1/watch-queue/dismiss
POST /api/v1/watchlist
POST /api/v1/webhooks/jellyfin
POST /api/v1/webhooks/plex
POST /api/v1/wrapups/generate
PUT /api/v1/goals/{year}
PUT /api/v1/import/sessions/{id}/mapping
PUT /api/v1/profile
PUT /api/v1/profile/fields
PUT /api/v1/settings

View File

@@ -0,0 +1,54 @@
DELETE /api/v1/goals/{year}
DELETE /api/v1/import/profiles/{id}
DELETE /api/v1/reviews/{id}
DELETE /api/v1/settings/webhook-tokens/{id}
DELETE /api/v1/users/me
DELETE /api/v1/users/{id}
DELETE /api/v1/watchlist/{movie_id}
DELETE /api/v1/wrapups/{id}
GET /api/v1/activity-feed
GET /api/v1/diary
GET /api/v1/diary/export
GET /api/v1/goals
GET /api/v1/import/profiles
GET /api/v1/import/sessions/{id}
GET /api/v1/insights
GET /api/v1/movies
GET /api/v1/movies/{id}/history
GET /api/v1/movies/{id}/profile
GET /api/v1/movies/{movie_id}
GET /api/v1/people/{id}
GET /api/v1/people/{id}/credits
GET /api/v1/profile
GET /api/v1/search
GET /api/v1/settings
GET /api/v1/settings/webhook-tokens
GET /api/v1/users
GET /api/v1/users/{id}
GET /api/v1/users/{id}/goals
GET /api/v1/watch-queue
GET /api/v1/watchlist
GET /api/v1/watchlist/{movie_id}
GET /api/v1/wrapups
GET /api/v1/wrapups/{id}
GET /api/v1/wrapups/{id}/report
POST /api/v1/auth/login
POST /api/v1/auth/register
POST /api/v1/goals
POST /api/v1/import/profiles
POST /api/v1/import/sessions
POST /api/v1/import/sessions/{id}/confirm
POST /api/v1/movies/{id}/sync-poster
POST /api/v1/reviews
POST /api/v1/settings/webhook-tokens
POST /api/v1/watch-queue/confirm
POST /api/v1/watch-queue/dismiss
POST /api/v1/watchlist
POST /api/v1/webhooks/jellyfin
POST /api/v1/webhooks/plex
POST /api/v1/wrapups/generate
PUT /api/v1/goals/{year}
PUT /api/v1/import/sessions/{id}/mapping
PUT /api/v1/profile
PUT /api/v1/profile/fields
PUT /api/v1/settings

View File

@@ -0,0 +1,9 @@
#[path = "http_handlers/support.rs"]
mod support;
#[path = "http_handlers/api_handlers_test.rs"]
mod api_handlers_test;
#[path = "http_handlers/app_context_test.rs"]
mod app_context_test;
#[path = "http_handlers/extractors_test.rs"]
mod extractors_test;

View File

@@ -0,0 +1,553 @@
use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_repos};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::errors::DomainError;
use domain::ports::UserRepository;
use domain::testing::InMemoryStore;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
// Custom stub for SearchPort that returns empty results instead of panicking
struct SearchPortStub;
#[async_trait::async_trait]
impl domain::ports::SearchPort for SearchPortStub {
async fn search(
&self,
_: &domain::models::SearchQuery,
) -> Result<domain::models::SearchResults, DomainError> {
Ok(domain::models::SearchResults {
movies: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
people: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
})
}
}
// Custom stub for PersonQuery that returns 404 instead of panicking
struct PersonQueryStub;
#[async_trait::async_trait]
impl domain::ports::PersonQuery for PersonQueryStub {
async fn get_by_id(
&self,
_: &domain::models::PersonId,
) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None) // Return None to trigger 404
}
async fn get_by_external_id(
&self,
_: &domain::models::ExternalPersonId,
) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None)
}
async fn get_credits(
&self,
_: &domain::models::PersonId,
) -> Result<domain::models::PersonCredits, DomainError> {
Err(DomainError::NotFound("Person not found".into()))
}
async fn list_orphaned_persons(&self) -> Result<Vec<domain::models::PersonId>, DomainError> {
Ok(vec![])
}
async fn list_page(
&self,
_limit: u32,
_offset: u32,
) -> Result<Vec<domain::models::Person>, DomainError> {
Ok(vec![])
}
}
// --- Search endpoint tests ---
#[tokio::test]
async fn search_endpoint_returns_200_with_empty_results() {
let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(http_axum::api::search::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=test&limit=10&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_endpoint_with_no_query_returns_200() {
let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(http_axum::api::search::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=&limit=5&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// --- Person endpoint tests ---
#[tokio::test]
async fn person_endpoint_returns_404_for_unknown_id() {
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(http_axum::api::search::get_person_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn person_credits_endpoint_returns_404_for_unknown_id() {
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}/credits",
get(http_axum::api::search::get_person_credits_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}/credits", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn injected_stub_reaches_prebuilt_deps() {
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
let state = crate::support::test_app_state_with_person_query(Arc::clone(&stub));
assert!(
Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub),
"prebuilt deps must hold the injected stub too, or handlers reading deps \
will silently use the real dependency"
);
}
// --- Watchlist endpoint tests ---
#[tokio::test]
async fn get_watchlist_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist",
get(http_axum::api::watchlist::get_watchlist_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Pending-follower-count endpoint tests ---
struct FixedPendingCount(usize);
#[async_trait::async_trait]
impl domain::ports::FollowGraphQuery for FixedPendingCount {
async fn get_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn count_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(self.0)
}
async fn get_relation(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<domain::value_objects::FollowRelation, DomainError> {
unreachable!()
}
}
#[tokio::test]
async fn pending_count_endpoint_reports_the_use_case_result() {
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
let users = InMemoryStore::new();
users
.save(&domain::testing::test_user_with_role(
uid.clone(),
domain::models::UserRole::Standard,
))
.await
.unwrap();
let state = test_app_state_with_repos(
|repos| {
repos.follow_graph = Arc::new(FixedPendingCount(3));
repos.user = users as _;
},
Arc::new(AcceptingAuth(uid)),
);
let app = Router::new()
.route(
"/api/v1/social/followers/pending/count",
get(http_axum::api::social::get_pending_follower_count),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/social/followers/pending/count")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["count"], 3);
}
#[tokio::test]
async fn get_watchlist_status_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist/{movie_id}",
get(http_axum::api::watchlist::get_watchlist_status),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Federation collection error-handling tests ---
#[cfg(feature = "federation")]
struct FailingApDocument;
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::ApDocumentPort for FailingApDocument {
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn followers_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn following_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
}
#[cfg(feature = "federation")]
fn state_with_failing_ap_document() -> http_axum::state::AppState {
let mut state = test_app_state();
state.app_ctx.ap_document = Arc::new(FailingApDocument);
state
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_collection_returns_500_on_federation_error() {
let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/followers", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn following_collection_returns_500_on_federation_error() {
let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/following", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
async fn refusal_body(resp: axum::response::Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).expect("refusal body is JSON")
}
#[tokio::test]
async fn a_missing_resource_is_refused_with_not_found_code() {
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(http_axum::api::search::get_person_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}", Uuid::new_v4()))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = refusal_body(resp).await;
assert_eq!(body["code"], "NOT_FOUND");
assert!(body["error"].is_string());
}
#[tokio::test]
async fn a_missing_credential_is_refused_with_unauthorized_code() {
let app = Router::new()
.route(
"/api/v1/watchlist",
get(http_axum::api::watchlist::get_watchlist_handler),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(refusal_body(resp).await["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() {
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(http_axum::api::search::get_person_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/people/not-a-uuid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
}
#[tokio::test]
async fn a_malformed_json_body_is_refused_with_invalid_input_code() {
let app = Router::new()
.route(
"/api/v1/auth/login",
axum::routing::post(http_axum::api::auth::login),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from("{not json"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = refusal_body(resp).await;
assert_eq!(body["code"], "INVALID_INPUT");
assert!(body["error"].is_string());
}
#[tokio::test]
async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() {
let app = Router::new()
.route(
"/api/v1/auth/login",
axum::routing::post(http_axum::api::auth::login),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
}
#[tokio::test]
async fn a_non_multipart_upload_is_refused_with_invalid_input_code() {
let app = Router::new()
.route(
"/api/v1/webhooks/plex",
axum::routing::post(http_axum::api::webhook::post_plex_webhook),
)
.with_state(test_app_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/webhooks/plex?token=t")
.header("content-type", "text/plain")
.body(Body::from("hello"))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_client_error());
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
}

View File

@@ -0,0 +1,24 @@
#[test]
fn prebuilt_deps_share_port_instances_with_repos() {
use std::sync::Arc;
let repos = crate::support::test_repositories();
let user = Arc::clone(&repos.user);
let follow_graph = Arc::clone(&repos.follow_graph);
let state =
crate::support::build_test_state_from(repos, Arc::new(domain::testing::PanicAuthService));
assert!(
Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user),
"deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from"
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph),
"deps.social.query.follow_graph must be the same Arc as the Repositories it was built from"
);
assert_eq!(
state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance,
"prebuilt deps must carry the context's InstanceIdentity"
);
}

View File

@@ -0,0 +1,408 @@
use std::sync::Arc;
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::{
errors::DomainError, models::GeneratedToken, ports::AuthService, testing::InMemoryStore,
value_objects::UserId,
};
use http_axum::extractors::{
AdminApiUser, AdminUser, AuthenticatedUser, OptionalCookieUser, RequiredCookieUser,
};
use tower::ServiceExt;
use crate::support::{
AcceptingAuth, ErroringUserRepo, make_test_state, test_app_state, test_app_state_with_user,
};
struct RejectingAuth;
#[async_trait::async_trait]
impl AuthService for RejectingAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Err(DomainError::Unauthorized("bad token".into()))
}
}
// --- Routers ---
async fn protected_handler(user: AuthenticatedUser) -> String {
user.0.value().to_string()
}
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
match user.0 {
Some(id) => id.value().to_string(),
None => "none".to_string(),
}
}
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
user.0.value().to_string()
}
async fn admin_api_handler(user: AdminApiUser) -> String {
user.0.value().to_string()
}
async fn admin_cookie_handler(user: AdminUser) -> String {
user.0.value().to_string()
}
fn router_protected(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/protected", get(protected_handler))
.with_state(state)
}
fn router_optional(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/optional", get(optional_cookie_handler))
.with_state(state)
}
fn router_admin_api(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/admin-api", get(admin_api_handler))
.with_state(state)
}
fn router_admin_cookie(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/admin-cookie", get(admin_cookie_handler))
.with_state(state)
}
fn router_required(state: http_axum::state::AppState) -> Router {
Router::new()
.route("/required", get(required_cookie_handler))
.with_state(state)
}
// --- Tests ---
#[tokio::test]
async fn missing_auth_header_returns_401() {
let app = router_protected(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/protected")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn optional_cookie_user_returns_none_without_cookie() {
let app = router_optional(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none");
}
#[tokio::test]
async fn optional_cookie_user_returns_none_with_invalid_token() {
let app = router_optional(make_test_state(Arc::new(RejectingAuth)));
let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none");
}
#[tokio::test]
async fn required_cookie_user_redirects_without_cookie() {
let app = router_required(test_app_state());
let resp = app
.oneshot(
Request::builder()
.uri("/required")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn required_cookie_user_redirects_with_invalid_token() {
let app = router_required(make_test_state(Arc::new(RejectingAuth)));
let resp = app
.oneshot(
Request::builder()
.uri("/required")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
// --- AdminApiUser / AdminUser: per-path rejection pinning ---
//
// These pin the exact rejection for every path `authorize_admin::execute` can
// produce, now that both extractors go through it instead of calling
// `repos.user.find_by_id` directly. Each test name states the path; the
// asserted status is the byte-identical-to-before-this-task value recorded in
// the task-4 report's before/after table.
fn admin_id() -> UserId {
UserId::generate()
}
#[tokio::test]
async fn admin_api_user_allows_an_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Admin,
));
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_api_user_rejects_a_non_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Standard,
));
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_api_user_rejects_an_unknown_user() {
let id = admin_id();
let user_repo = InMemoryStore::new(); // empty — id is not in the store
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_api_user_surfaces_a_repository_error_as_500() {
let id = admin_id();
let app = router_admin_api(test_app_state_with_user(
Arc::new(ErroringUserRepo),
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn admin_user_allows_an_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Admin,
));
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_user_rejects_a_non_admin_user() {
let id = admin_id();
let user_repo = InMemoryStore::new();
user_repo.seed_user(domain::testing::test_user_with_role(
id.clone(),
domain::models::UserRole::Standard,
));
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_user_rejects_an_unknown_user() {
let id = admin_id();
let user_repo = InMemoryStore::new(); // empty — id is not in the store
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn admin_user_surfaces_a_repository_error_as_500() {
let id = admin_id();
let app = router_admin_cookie(test_app_state_with_user(
Arc::new(ErroringUserRepo),
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
async fn path_id_handler(
http_axum::extractors::PathId(id): http_axum::extractors::PathId<u32>,
) -> String {
id.to_string()
}
async fn refusal_code(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("refusal body is JSON");
v["code"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn path_extractor_on_a_route_without_params_is_refused_as_internal() {
let app = Router::new().route("/no-params", get(path_id_handler));
let resp = app
.oneshot(
Request::builder()
.uri("/no-params")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(refusal_code(resp).await, "INTERNAL");
}
#[tokio::test]
async fn path_extractor_with_an_unparseable_param_is_refused_as_invalid_input() {
let app = Router::new().route("/items/{id}", get(path_id_handler));
let resp = app
.oneshot(
Request::builder()
.uri("/items/abc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert_eq!(refusal_code(resp).await, "INVALID_INPUT");
}

View File

@@ -0,0 +1,259 @@
#![allow(dead_code)]
use application::config::AppSettings;
use bootstrap::Repositories;
use domain::{
errors::DomainError,
models::GeneratedToken,
ports::{AuthService, PersonQuery, SearchPort, UserRepository},
testing::{
PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser,
PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository,
PanicImportSessionRepository, PanicInsightsQuery, PanicMetadataClient, PanicMovieCommand,
PanicMovieProfileRepository, PanicMovieQuery, PanicObjectStorage, PanicPasswordHasher,
PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo,
PanicRefreshSessionRepository, PanicRemoteGoalRepository, PanicRemoteWatchlistRepository,
PanicReviewRepository, PanicRssFeedRenderer, PanicSearchCommand, PanicSearchPort,
PanicStatsRepository, PanicUserRepository, PanicUserSettingsRepository,
PanicWatchEventCommand, PanicWatchEventQuery, PanicWatchlistRepository,
PanicWebhookTokenRepository, PanicWrapUpRepository, PanicWrapUpStatsQuery,
},
value_objects::UserId,
};
use http_axum::context::AppContext;
use std::sync::Arc;
struct PanicReviewLogger;
#[async_trait::async_trait]
impl application::ports::ReviewLogger for PanicReviewLogger {
async fn log_review(
&self,
_: application::diary::commands::LogReviewCommand,
) -> Result<(), DomainError> {
panic!()
}
}
// --- Repositories factory — `Panic*` stubs everywhere ---
pub fn test_repositories() -> Repositories {
Repositories {
movie_command: Arc::new(PanicMovieCommand) as _,
movie_query: Arc::new(PanicMovieQuery) as _,
review: Arc::new(PanicReviewRepository) as _,
diary: Arc::new(PanicDiaryQuery) as _,
stats: Arc::new(PanicStatsRepository) as _,
user: Arc::new(PanicUserRepository) as _,
import_session: Arc::new(PanicImportSessionRepository) as _,
import_profile: Arc::new(PanicImportProfileRepository) as _,
movie_profile: Arc::new(PanicMovieProfileRepository) as _,
watchlist: Arc::new(PanicWatchlistRepository) as _,
watch_event_command: Arc::new(PanicWatchEventCommand) as _,
watch_event_query: Arc::new(PanicWatchEventQuery) as _,
webhook_token: Arc::new(PanicWebhookTokenRepository) as _,
profile_fields: Arc::new(PanicProfileFieldsRepo) as _,
person_command: Arc::new(PanicPersonCommand) as _,
person_query: Arc::new(PanicPersonQuery) as _,
search_port: Arc::new(PanicSearchPort) as _,
search_command: Arc::new(PanicSearchCommand) as _,
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository) as _,
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
wrapup_stats: Arc::new(PanicWrapUpStatsQuery) as _,
wrapup_repo: Arc::new(PanicWrapUpRepository) as _,
insights: Arc::new(PanicInsightsQuery) as _,
goal_command: Arc::new(PanicGoalCommand) as _,
goal_query: Arc::new(PanicGoalQuery) as _,
user_settings: Arc::new(PanicUserSettingsRepository) as _,
remote_goal: Arc::new(PanicRemoteGoalRepository) as _,
refresh_session: Arc::new(PanicRefreshSessionRepository) as _,
federated_profile: None,
}
}
// --- Single state factory — builds `deps` from whatever `repos` it is given ---
/// Builds an `AppState` from caller-supplied `repos`, running `bootstrap::build_deps`
/// AFTER any overrides the caller already applied to `repos`. This is the only place
/// `deps` gets built for tests, so `repos` and `deps` can never disagree.
pub fn build_test_state_from(
repos: Repositories,
auth_service: Arc<dyn AuthService>,
) -> http_axum::state::AppState {
let services = application::Services {
auth: auth_service,
password_hasher: Arc::new(PanicPasswordHasher) as _,
metadata: Arc::new(PanicMetadataClient) as _,
poster_fetcher: Arc::new(PanicPosterFetcher) as _,
object_storage: Arc::new(PanicObjectStorage) as _,
event_publisher: Arc::new(PanicEventPublisher) as _,
diary_exporter: Arc::new(PanicDiaryExporter) as _,
document_parser: Arc::new(PanicDocumentParser) as _,
review_logger: Arc::new(PanicReviewLogger) as _,
person_enrichment: None,
};
let config = AppSettings {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
secure_cookies: false,
cors_origins: String::new(),
wrapup: application::config::WrapUpSettings {
font_path: None,
logo_path: None,
bg_dir: None,
},
};
let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000");
let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance));
http_axum::state::AppState {
app_ctx: AppContext {
deps,
services,
config,
instance,
#[cfg(feature = "federation")]
ap_document: Arc::new(domain::ports::noop::NoopApDocument),
#[cfg(feature = "federation")]
ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist),
},
rss_renderer: Arc::new(PanicRssFeedRenderer),
}
}
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> http_axum::state::AppState {
build_test_state_from(test_repositories(), auth_service)
}
/// Reusable default test `AppState`: `Panic*` stubs everywhere, real prebuilt `deps`.
/// Callers that need a specific stub swapped in should use one of the
/// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps`
/// runs — mutating the returned `AppState` afterwards would only reach `repos`,
/// not the prebuilt `deps`.
pub fn test_app_state() -> http_axum::state::AppState {
make_test_state(Arc::new(PanicAuthService))
}
/// Test state with `person_query` overridden BEFORE `build_deps` runs, so the
/// stub is visible from both `app_ctx.repos.person_query` and every prebuilt
/// deps struct that carries a `person_query` (e.g. `deps.person.get_person`).
pub fn test_app_state_with_person_query(
person_query: Arc<dyn PersonQuery>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
repos.person_query = person_query;
build_test_state_from(repos, Arc::new(PanicAuthService))
}
/// Test state with `search_port` overridden BEFORE `build_deps` runs, so the
/// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps
/// struct that carries a `search_port`.
pub fn test_app_state_with_search_port(
search_port: Arc<dyn SearchPort>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
repos.search_port = search_port;
build_test_state_from(repos, Arc::new(PanicAuthService))
}
pub fn test_app_state_with_repos(
adjust: impl FnOnce(&mut Repositories),
auth_service: Arc<dyn AuthService>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
adjust(&mut repos);
build_test_state_from(repos, auth_service)
}
/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is
/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user`
/// — the field the `AdminApiUser`/`AdminUser` extractors now read through.
pub fn test_app_state_with_user(
user: Arc<dyn UserRepository>,
auth_service: Arc<dyn AuthService>,
) -> http_axum::state::AppState {
let mut repos = test_repositories();
repos.user = user;
build_test_state_from(repos, auth_service)
}
/// A `UserRepository` whose `find_by_id` always fails, for exercising the
/// repository-error rejection path of the admin extractors. Every other
/// method panics — the admin extractors only ever call `find_by_id`.
pub struct ErroringUserRepo;
#[async_trait::async_trait]
impl UserRepository for ErroringUserRepo {
async fn find_by_email(
&self,
_: &domain::value_objects::Email,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
panic!()
}
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> {
Err(DomainError::InfrastructureError("db down".into()))
}
async fn find_by_username(
&self,
_: &domain::value_objects::Username,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
async fn update_profile(
&self,
_: &UserId,
_: &domain::models::UserProfile,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
panic!()
}
async fn mark_deleted(
&self,
_: &UserId,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
panic!()
}
async fn find_tombstoned_by_id(
&self,
_: &UserId,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn is_username_retired(
&self,
_: &domain::value_objects::Username,
) -> Result<bool, DomainError> {
panic!()
}
async fn count_active_admins(&self) -> Result<u64, DomainError> {
panic!()
}
}
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,
/// for tests that need to authenticate as a specific user rather than merely
/// exercise the unauthenticated-request path.
pub struct AcceptingAuth(pub UserId);
#[async_trait::async_trait]
impl AuthService for AcceptingAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Ok(self.0.clone())
}
}

View File

@@ -0,0 +1,99 @@
mod support;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use application::worker::WorkerService;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{AckHandle, DomainEvent, EventEnvelope, EventId},
ports::{Begin, EventConsumer, EventHandler, ProcessedEventLedger},
};
use futures::stream::BoxStream;
use sqlite::SqliteProcessedEventLedger;
struct Counting(Arc<AtomicU32>);
#[async_trait]
impl EventHandler for Counting {
fn name(&self) -> &'static str {
"crashy"
}
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
struct Outcome(Arc<Mutex<Vec<&'static str>>>);
#[async_trait]
impl AckHandle for Outcome {
async fn ack(&self) -> Result<(), DomainError> {
self.0.lock().unwrap().push("ack");
Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
self.0.lock().unwrap().push("nack");
Ok(())
}
async fn defer(&self, _: Duration) -> Result<(), DomainError> {
self.0.lock().unwrap().push("defer");
Ok(())
}
}
struct Once(Mutex<Option<EventEnvelope>>);
impl EventConsumer for Once {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let item = self.0.lock().unwrap().take().map(Ok);
Box::pin(futures::stream::iter(item))
}
}
#[tokio::test]
async fn an_abandoned_claim_defers_deliveries_until_the_lease_lapses_then_runs_once() {
let pool = support::memory_pool().await;
sqlite::migrate(&pool).await.unwrap();
let ledger = Arc::new(SqliteProcessedEventLedger::with_lease(
pool,
Duration::from_secs(1),
));
let id = EventId::new();
assert!(matches!(
ledger.begin(&id, "crashy").await.unwrap(),
Begin::Run(_)
));
let runs = Arc::new(AtomicU32::new(0));
let outcomes = Arc::new(Mutex::new(Vec::new()));
let deliver = || {
let envelope = EventEnvelope::new(
Some(id),
DomainEvent::SearchReindexRequested,
Box::new(Outcome(outcomes.clone())),
);
let service = WorkerService::new(
Arc::new(Once(Mutex::new(Some(envelope)))),
vec![Arc::new(Counting(runs.clone()))],
ledger.clone(),
)
.unwrap();
async move { service.run(tokio::sync::watch::channel(false).1).await }
};
deliver().await;
assert_eq!(*outcomes.lock().unwrap(), ["defer"]);
assert_eq!(runs.load(Ordering::SeqCst), 0);
tokio::time::sleep(Duration::from_millis(2100)).await;
deliver().await;
assert_eq!(*outcomes.lock().unwrap(), ["defer", "ack"]);
assert_eq!(runs.load(Ordering::SeqCst), 1);
deliver().await;
assert_eq!(runs.load(Ordering::SeqCst), 1, "done claims never re-run");
}

View File

@@ -0,0 +1,209 @@
mod support;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Notify;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventId},
ports::{EventPublisher, EventRelaySink},
value_objects::UserId,
};
use sqlite::SqliteEventQueue;
use uuid::Uuid;
#[derive(Default)]
struct FakeSink {
fail: bool,
fail_first: std::sync::atomic::AtomicUsize,
slow: Option<Duration>,
started: Notify,
seen: Mutex<Vec<Option<EventId>>>,
}
#[async_trait]
impl EventRelaySink for FakeSink {
async fn relay(&self, id: Option<&EventId>, _event: &DomainEvent) -> Result<(), DomainError> {
self.seen.lock().unwrap().push(id.cloned());
if let Some(delay) = self.slow {
self.started.notify_one();
tokio::time::sleep(delay).await;
}
let outage = self
.fail_first
.try_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|n| n.checked_sub(1),
)
.is_ok();
if self.fail || outage {
Err(DomainError::InfrastructureError("bus down".into()))
} else {
Ok(())
}
}
}
async fn outbox() -> (sqlx::SqlitePool, Arc<dyn EventPublisher>, SqliteEventQueue) {
outbox_with_attempts(config::EventsConfig::default().queue_max_attempts).await
}
async fn outbox_with_attempts(
max_attempts: i32,
) -> (sqlx::SqlitePool, Arc<dyn EventPublisher>, SqliteEventQueue) {
let pool = support::memory_pool().await;
sqlite::migrate(&pool).await.unwrap();
let config = config::EventsConfig {
queue_poll_interval_ms: 10,
queue_max_attempts: max_attempts,
..Default::default()
};
let queue = SqliteEventQueue::create(
pool.clone(),
sqlite::DbEventQueueConfig::from_config(&config),
);
(pool.clone(), Arc::new(queue.clone()), queue)
}
async fn row(pool: &sqlx::SqlitePool) -> (String, i32, Option<String>) {
sqlx::query_as("SELECT status, attempts, event_id FROM event_queue")
.fetch_one(pool)
.await
.unwrap()
}
async fn wait_for(pool: &sqlx::SqlitePool, status: &str) {
for _ in 0..800 {
if row(pool).await.0 == status {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("row never reached {status}: {:?}", row(pool).await);
}
fn start(
queue: SqliteEventQueue,
sink: Arc<FakeSink>,
) -> (
tokio::sync::watch::Sender<bool>,
tokio::task::JoinHandle<()>,
) {
let (stop, rx) = tokio::sync::watch::channel(false);
let relay = bootstrap::outbox_relay::OutboxRelay::new(Arc::new(queue), sink)
.with_backoff(|_| Duration::from_millis(10));
(stop, tokio::spawn(relay.run(rx)))
}
fn event() -> DomainEvent {
DomainEvent::UserUpdated {
user_id: UserId::from_uuid(Uuid::new_v4()),
}
}
#[tokio::test]
async fn relay_marks_rows_done_only_after_publish_succeeds() {
let (pool, publisher, queue) = outbox().await;
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink::default());
let (stop, relay) = start(queue, sink.clone());
wait_for(&pool, "done").await;
relay.abort();
drop(stop);
let (_, attempts, event_id) = row(&pool).await;
assert_eq!(attempts, 0);
let seen = sink.seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(
seen[0].as_ref().map(|id| id.value().to_string()),
event_id,
"the sink gets the row's own event id"
);
}
#[tokio::test]
async fn a_failing_bus_never_spends_the_rows_attempts_and_the_row_relays_on_recovery() {
let (pool, publisher, queue) = outbox_with_attempts(2).await;
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink {
fail_first: std::sync::atomic::AtomicUsize::new(3),
..Default::default()
});
let (stop, relay) = start(queue, sink.clone());
wait_for(&pool, "done").await;
relay.abort();
drop(stop);
let (status, attempts, _) = row(&pool).await;
assert_eq!((status.as_str(), attempts), ("done", 0));
assert_eq!(
sink.seen.lock().unwrap().len(),
4,
"3 failures then success"
);
}
#[tokio::test]
async fn a_failed_publish_defers_the_row_without_counting_an_attempt() {
let (pool, publisher, queue) = outbox().await;
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink {
fail: true,
..Default::default()
});
let (stop, relay) = start(queue, sink.clone());
for _ in 0..200 {
let last_error: Option<String> = sqlx::query_scalar("SELECT last_error FROM event_queue")
.fetch_one(&pool)
.await
.unwrap();
if last_error.as_deref() == Some("deferred") {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
relay.abort();
drop(stop);
let (status, attempts, _) = row(&pool).await;
assert_eq!((status.as_str(), attempts), ("pending", 0));
let last_error: Option<String> = sqlx::query_scalar("SELECT last_error FROM event_queue")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(last_error.as_deref(), Some("deferred"));
}
#[tokio::test]
async fn shutdown_lets_the_in_flight_row_finish_then_stops_the_relay() {
let (pool, publisher, queue) = outbox().await;
publisher.publish(&event()).await.unwrap();
publisher.publish(&event()).await.unwrap();
let sink = Arc::new(FakeSink {
slow: Some(Duration::from_millis(200)),
..Default::default()
});
let (stop, relay) = start(queue, sink.clone());
sink.started.notified().await;
stop.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(5), relay)
.await
.expect("relay stops on shutdown")
.unwrap();
let statuses: Vec<String> = sqlx::query_scalar("SELECT status FROM event_queue ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(statuses[0], "done", "in-flight row was completed");
assert_ne!(statuses[0], "processing");
assert_eq!(sink.seen.lock().unwrap().len(), 1, "no new row after stop");
}

View File

@@ -0,0 +1,298 @@
mod support;
use axum::{
Router,
body::Body,
http::{Method, Request, header},
};
use support::{get, json_request, send};
const EMAIL: &str = "owner@example.com";
const USERNAME: &str = "owner";
const CLASSIC_VISITOR_PAGES: &[(&str, u16)] = &[
("/", 200),
("/users", 200),
("/u/{username}", 308),
("/users/{id}", 200),
("/users/{id}?view=ratings", 200),
("/users/{id}?view=history", 200),
("/movies/{movie}", 200),
("/login", 200),
("/register", 200),
("/logout", 303),
("/tags/drama", 307),
("/users/{id}/watchlist", 200),
("/wrapups/{id}/2025", 200),
("/wrapups/global/2025", 200),
("/users/{id}/following-list", 303),
("/users/{id}/followers-list", 303),
("/posters/abc.jpg", 308),
("/images/missing.jpg", 404),
("/health", 200),
];
const CLASSIC_OWNER_PAGES: &[(&str, u16)] = &[
("/reviews/new", 200),
("/diary/export", 200),
("/import", 200),
("/import/done", 200),
("/import/00000000-0000-0000-0000-000000000000/mapping", 303),
("/import/00000000-0000-0000-0000-000000000000/preview", 303),
("/settings/profile", 200),
("/settings/integrations", 200),
("/watch-queue", 200),
("/users/{id}/watchlist", 200),
("/users/{id}/following-list", 200),
("/users/{id}/followers-list", 200),
("/movies/{movie}", 200),
("/users/{id}?embed=true", 200),
];
#[cfg(feature = "federation")]
const FEDERATION_VISITOR_PAGES: &[(&str, u16)] = &[
("/users/{id}/followers", 303),
("/users/{id}/following", 303),
];
#[cfg(feature = "federation")]
const FEDERATION_OWNER_PAGES: &[(&str, u16)] = &[
("/users/{id}/followers", 303),
("/users/{id}/following", 303),
("/admin/blocked-domains", 403),
("/social/blocked", 200),
];
#[cfg(not(feature = "federation"))]
const FEDERATION_OFF_PAGES: &[(&str, u16)] = &[
("/users/{id}/followers", 308),
("/users/{id}/following", 308),
];
struct Seed {
router: Router,
user_id: String,
movie_id: String,
owner_cookie: String,
}
async fn seed() -> Seed {
let (router, pool) = support::server().await;
let session = support::register(&router, USERNAME, EMAIL).await;
let (token, user_id, owner_cookie) = (session.token, session.user_id, session.cookie);
let (status, _, body) = send(
&router,
json_request(
Method::POST,
"/api/v1/reviews",
Some(&token),
serde_json::json!({
"manual_title": "Seeded Movie",
"manual_release_year": 1999,
"manual_director": "Some Director",
"rating": 4,
"comment": "the butler did it",
"watched_at": "2025-03-01",
"spoilers": true,
}),
),
)
.await;
assert_eq!(status, 201, "log review: {body}");
let (status, _, body) = send(
&router,
json_request(
Method::POST,
"/api/v1/goals",
Some(&token),
serde_json::json!({ "year": 2025, "target_count": 12 }),
),
)
.await;
assert_eq!(status, 200, "create goal: {body}");
let (status, _, body) = send(
&router,
Request::builder()
.uri("/api/v1/movies")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, 200, "list movies: {body}");
let movies: serde_json::Value = serde_json::from_str(&body).unwrap();
let movie_id = movies["items"][0]["id"]
.as_str()
.unwrap_or_else(|| panic!("no movie id in {body}"))
.to_string();
seed_ready_wrapup(&pool, Some(&user_id)).await;
seed_ready_wrapup(&pool, None).await;
Seed {
router,
user_id,
movie_id,
owner_cookie,
}
}
async fn seed_ready_wrapup(pool: &sqlx::SqlitePool, user_id: Option<&str>) {
use chrono::NaiveDate;
use domain::models::wrapup::{DateRange, WrapUpScope};
let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let scope = match user_id {
Some(id) => WrapUpScope::User(id.parse().unwrap()),
None => WrapUpScope::Global,
};
let report = domain::services::wrapup_analyzer::build_report(
scope,
DateRange::new(start, end).unwrap(),
&[],
);
sqlx::query(
"INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json) \
VALUES (?, ?, '2025-01-01', '2026-01-01', 'ready', ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user_id)
.bind(serde_json::to_string(&report).unwrap())
.execute(pool)
.await
.unwrap();
}
fn expand(seed: &Seed, template: &str) -> String {
template
.replace("{id}", &seed.user_id)
.replace("{username}", USERNAME)
.replace("{movie}", &seed.movie_id)
}
async fn check_table(seed: &Seed, table: &[(&str, u16)], cookie: Option<&str>) {
let mut failures = Vec::new();
for (template, expected) in table {
let uri = expand(seed, template);
let (status, _, _) = send(&seed.router, get(&uri, cookie)).await;
if status != *expected {
failures.push(format!("{uri}: expected {expected}, got {status}"));
}
}
assert!(
failures.is_empty(),
"route status drift:\n{}",
failures.join("\n")
);
}
#[tokio::test]
async fn every_classic_page_renders_for_a_visitor() {
let seed = seed().await;
check_table(&seed, CLASSIC_VISITOR_PAGES, None).await;
#[cfg(feature = "federation")]
check_table(&seed, FEDERATION_VISITOR_PAGES, None).await;
#[cfg(not(feature = "federation"))]
check_table(&seed, FEDERATION_OFF_PAGES, None).await;
for template in ["/wrapups/{id}/2025", "/wrapups/global/2025"] {
let (status, _, body) = send(&seed.router, get(&expand(&seed, template), None)).await;
assert_eq!(status, 200, "{template}");
assert!(
body.contains("class=\"wu-container\""),
"wrapup template not rendered for {template}"
);
}
let (status, _, body) = send(
&seed.router,
get(&expand(&seed, "/users/{id}?embed=true"), None),
)
.await;
assert_eq!(status, 200);
assert!(
body.contains("class=\"embed-profile\""),
"embed template not rendered: {body}"
);
}
#[tokio::test]
async fn every_classic_page_renders_for_the_signed_in_owner() {
let seed = seed().await;
let cookie = seed.owner_cookie.clone();
check_table(&seed, CLASSIC_OWNER_PAGES, Some(&cookie)).await;
#[cfg(feature = "federation")]
check_table(&seed, FEDERATION_OWNER_PAGES, Some(&cookie)).await;
}
#[tokio::test]
async fn feeds_and_openapi_are_served() {
let seed = seed().await;
for (template, expected) in [
("/feed.rss", 200),
("/users/{id}/feed.rss", 200),
("/openapi.json", 200),
("/docs", 303),
("/docs/", 200),
] {
let uri = expand(&seed, template);
let (status, _, _) = send(&seed.router, get(&uri, None)).await;
assert_eq!(status, expected, "{uri}");
}
}
#[tokio::test]
async fn openapi_paths_match_the_recorded_list() {
let seed = seed().await;
let (status, _, body) = send(&seed.router, get("/openapi.json", None)).await;
assert_eq!(status, 200);
let spec: serde_json::Value = serde_json::from_str(&body).unwrap();
let mut lines = Vec::new();
for (path, item) in spec["paths"].as_object().unwrap() {
for method in item.as_object().unwrap().keys() {
if ["get", "put", "post", "delete", "patch", "head", "options"]
.contains(&method.as_str())
{
lines.push(format!("{} {path}", method.to_uppercase()));
}
}
}
lines.sort();
let actual = lines.join("\n") + "\n";
let fixture = if cfg!(feature = "federation") {
"tests/fixtures/openapi_paths.txt"
} else {
"tests/fixtures/openapi_paths_federation_off.txt"
};
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(fixture);
if std::env::var("UPDATE_OPENAPI_FIXTURE").is_ok() {
std::fs::write(&path, &actual).unwrap();
}
let expected = std::fs::read_to_string(&path).unwrap();
assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}");
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn profile_page_serves_the_actor_document_to_activitypub_clients() {
let seed = seed().await;
let req = Request::builder()
.method(Method::GET)
.uri(format!("/users/{}", seed.user_id))
.header(header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap();
let (status, headers, body) = send(&seed.router, req).await;
assert_eq!(status, 200, "{body}");
assert_eq!(
headers.get(header::CONTENT_TYPE).unwrap(),
"application/activity+json"
);
let actor: serde_json::Value = serde_json::from_str(&body).expect("actor JSON");
assert_eq!(actor["type"], "Person");
}

View File

@@ -0,0 +1,153 @@
#![allow(dead_code)]
use axum::{
Router,
body::Body,
http::{Method, Request, header},
};
use tower::ServiceExt;
pub const PASSWORD: &str = "correct-horse-battery";
pub fn test_config() -> config::AppConfig {
config::AppConfig {
server: config::ServerConfig {
allow_registration: true,
base_url: "http://localhost:3000".into(),
..Default::default()
},
database: config::DatabaseConfig {
url: "sqlite::memory:".into(),
},
auth: config::AuthConfig {
jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()),
..Default::default()
},
events: Default::default(),
nats: Default::default(),
storage: Default::default(),
image_conversion: Default::default(),
metadata: config::MetadataConfig {
tmdb_api_key: Some("test-key".into()),
..Default::default()
},
federation: Default::default(),
wrapup: Default::default(),
}
}
pub async fn send(router: &Router, mut req: Request<Body>) -> (u16, axum::http::HeaderMap, String) {
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(
status,
headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut b = Request::builder().method(Method::GET).uri(uri);
if let Some(c) = cookie {
b = b.header(header::COOKIE, c);
}
b.body(Body::empty()).unwrap()
}
pub fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find_map(|c| {
c.split(';')
.next()?
.trim()
.strip_prefix(&format!("{name}="))
.map(str::to_string)
})
}
pub fn json_request(
method: Method,
uri: &str,
token: Option<&str>,
body: serde_json::Value,
) -> Request<Body> {
let mut b = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(t) = token {
b = b.header(header::AUTHORIZATION, format!("Bearer {t}"));
}
b.body(Body::from(body.to_string())).unwrap()
}
pub async fn memory_pool() -> sqlx::SqlitePool {
sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap()
}
pub async fn server() -> (Router, sqlx::SqlitePool) {
let pool = memory_pool().await;
let router = bootstrap::build_server_on(&test_config(), pool.clone())
.await
.unwrap();
(router, pool)
}
pub struct Session {
pub user_id: String,
pub token: String,
pub cookie: String,
}
pub async fn register(router: &Router, username: &str, email: &str) -> Session {
let (status, headers, _) = send(router, get("/register", None)).await;
assert_eq!(status, 200, "GET /register");
let csrf = cookie_value(&headers, "csrf").expect("csrf cookie");
let form = format!(
"email={}&username={username}&password={PASSWORD}&_csrf={csrf}",
email.replace('@', "%40")
);
let req = Request::builder()
.method(Method::POST)
.uri("/register")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, format!("csrf={csrf}"))
.body(Body::from(form))
.unwrap();
let (status, headers, _) = send(router, req).await;
assert_eq!(status, 303, "POST /register");
let session = cookie_value(&headers, "token").expect("session cookie after register");
let cookie = format!("csrf={csrf}; token={session}");
let (status, _, body) = send(
router,
json_request(
Method::POST,
"/api/v1/auth/login",
None,
serde_json::json!({ "email": email, "password": PASSWORD }),
),
)
.await;
assert_eq!(status, 200, "login: {body}");
let login: serde_json::Value = serde_json::from_str(&body).unwrap();
Session {
user_id: login["user_id"].as_str().unwrap().to_string(),
token: login["token"].as_str().unwrap().to_string(),
cookie,
}
}