//! Integration tests for the admin audit-log viewer: the `repo::admin_audit` //! list query (filters, username join, NULL actor) and the //! `GET /v1/admin/audit` endpoint (admin-gating, shape, filter params). mod common; use axum::http::StatusCode; use axum::Router; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use common::{body_json, get_with_cookie, harness, register_user}; use mangalord::repo::admin_audit::{self, AuditFilter}; async fn seed_admin(pool: &PgPool, app: &Router) -> (Uuid, String) { let (username, cookie) = register_user(app).await; let u = mangalord::repo::user::find_by_username(pool, &username) .await .unwrap() .unwrap(); mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true) .await .unwrap(); (u.id, cookie) } /// Insert an audit row with an explicit `at` so ordering/since are testable. async fn insert_audit( pool: &PgPool, actor: Option, action: &str, target_kind: &str, at: &str, ) { sqlx::query( "INSERT INTO admin_audit (actor_user_id, action, target_kind, target_id, payload, at) \ VALUES ($1, $2, $3, NULL, $4, $5::timestamptz)", ) .bind(actor) .bind(action) .bind(target_kind) .bind(json!({ "k": action })) .bind(at) .execute(pool) .await .unwrap(); } #[sqlx::test(migrations = "./migrations")] async fn list_joins_username_and_orders_newest_first(pool: PgPool) { let h = harness(pool.clone()); let (admin_id, _cookie) = seed_admin(&pool, &h.app).await; insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await; insert_audit(&pool, None, "manga_resync", "manga", "2026-06-02T00:00:00Z").await; let (items, total) = admin_audit::list(&pool, AuditFilter::default(), 50, 0) .await .unwrap(); assert_eq!(total, 2); // Newest first. assert_eq!(items[0].action, "manga_resync"); assert_eq!(items[1].action, "crawler_run"); // Username join: the admin actor resolves; the NULL actor stays None. assert_eq!(items[1].actor_user_id, Some(admin_id)); assert!(items[1].actor_username.is_some()); assert_eq!(items[0].actor_user_id, None); assert_eq!(items[0].actor_username, None); } #[sqlx::test(migrations = "./migrations")] async fn list_filters_by_action_target_kind_actor_and_since(pool: PgPool) { let h = harness(pool.clone()); let (admin_id, _cookie) = seed_admin(&pool, &h.app).await; insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-05-01T00:00:00Z").await; insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-10T00:00:00Z").await; insert_audit(&pool, None, "crawler_run", "crawler", "2026-06-11T00:00:00Z").await; // Filter by action. let (items, total) = admin_audit::list( &pool, AuditFilter { action: Some("crawler_run"), ..Default::default() }, 50, 0, ) .await .unwrap(); assert_eq!(total, 2); assert!(items.iter().all(|r| r.action == "crawler_run")); // Filter by target_kind. let (_items, total) = admin_audit::list( &pool, AuditFilter { target_kind: Some("manga"), ..Default::default() }, 50, 0, ) .await .unwrap(); assert_eq!(total, 1); // Filter by actor. let (_items, total) = admin_audit::list( &pool, AuditFilter { actor_user_id: Some(admin_id), ..Default::default() }, 50, 0, ) .await .unwrap(); assert_eq!(total, 2); // Filter by since (only the two June rows). let since = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z") .unwrap() .with_timezone(&chrono::Utc); let (_items, total) = admin_audit::list( &pool, AuditFilter { since: Some(since), ..Default::default() }, 50, 0, ) .await .unwrap(); assert_eq!(total, 2); } #[sqlx::test(migrations = "./migrations")] async fn endpoint_requires_admin(pool: PgPool) { let h = harness(pool.clone()); let (_u, cookie) = register_user(&h.app).await; let resp = h .app .oneshot(get_with_cookie("/api/v1/admin/audit", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[sqlx::test(migrations = "./migrations")] async fn endpoint_returns_paged_shape_and_honors_filter(pool: PgPool) { let h = harness(pool.clone()); let (admin_id, cookie) = seed_admin(&pool, &h.app).await; insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await; insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-02T00:00:00Z").await; // Unfiltered: both rows + paged envelope. let resp = h .app .clone() .oneshot(get_with_cookie("/api/v1/admin/audit", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["page"]["total"], 2); assert_eq!(body["items"].as_array().unwrap().len(), 2); assert!(body["items"][0]["actor_username"].is_string()); // Filter by action. let resp = h .app .clone() .oneshot(get_with_cookie( "/api/v1/admin/audit?action=manga_resync", &cookie, )) .await .unwrap(); let body = body_json(resp).await; assert_eq!(body["page"]["total"], 1); assert_eq!(body["items"][0]["action"], "manga_resync"); assert_eq!(body["items"][0]["target_kind"], "manga"); }