fix(admin): make force-reanalyze audit transactional; document storage exception

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 07:16:08 +02:00
parent 5b76e0cc37
commit de510cc19a
8 changed files with 141 additions and 30 deletions

View File

@@ -74,6 +74,38 @@ async fn analyze_job_count(pool: &PgPool) -> i64 {
.unwrap()
}
#[sqlx::test(migrations = "./migrations")]
async fn force_analyze_enqueue_rolls_back_with_its_transaction(pool: PgPool) {
// The admin force-reanalyze handler runs enqueue_for_page_conn + the audit
// insert in one transaction. Prove the enqueue genuinely participates in
// that tx: when the tx is abandoned (the path taken if the audit insert
// fails and the `?` propagates), no job survives — so the audit trail can
// never miss a force-reanalyze that actually landed.
let page_id = seed_pages(&pool, 1).await[0];
let mut tx = pool.begin().await.unwrap();
let outcome =
mangalord::repo::page_analysis::enqueue_for_page_conn(&mut tx, page_id, true)
.await
.unwrap();
assert!(matches!(
outcome,
mangalord::repo::page_analysis::EnqueueForPageOutcome::Inserted
));
// Visible inside the open transaction…
let in_tx: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
)
.fetch_one(&mut *tx)
.await
.unwrap();
assert_eq!(in_tx, 1);
// …but gone once the tx rolls back instead of committing.
tx.rollback().await.unwrap();
assert_eq!(analyze_job_count(&pool).await, 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_upload_enqueues_one_analysis_job_per_page(pool: PgPool) {
let h = common::harness_with_analysis(pool.clone());
@@ -194,6 +226,38 @@ async fn reenqueue_backfills_existing_pages(pool: PgPool) {
assert_eq!(analyze_job_count(&pool).await, 3);
}
#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_writes_audit_row_atomically_with_jobs(pool: PgPool) {
// The enqueue and its admin_audit row are committed in one transaction,
// so a successful re-enqueue always leaves both the jobs AND exactly one
// matching audit row — the audit trail can't miss an enqueue that landed.
let h = common::harness_with_analysis(pool.clone());
let cookie = seed_admin(&pool, &h.app).await;
seed_pages(&pool, 2).await;
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/admin/analysis/reenqueue",
json!({ "only_unanalyzed": true }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(analyze_job_count(&pool).await, 2);
let audit: serde_json::Value = sqlx::query_scalar(
"SELECT payload FROM admin_audit WHERE action = 'analysis_reenqueue'",
)
.fetch_one(&pool)
.await
.expect("exactly one analysis_reenqueue audit row committed with the jobs");
assert_eq!(audit["enqueued"], 2);
assert_eq!(audit["only_unanalyzed"], true);
}
#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_scoped_to_manga(pool: PgPool) {
let h = common::harness_with_analysis(pool.clone());