From b07a63d52275549da71a0d5fdb70bb7e7394f960 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:15:03 +0200 Subject: [PATCH 01/19] fix(test): bound CleanupGuard::drop join to 30s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleanupGuard::drop currently calls .join() with no timeout. When test cleanup hangs (DB pool contention, slow DELETE, lock wait, etc.), the test process holds until nextest's 120s slow-timeout kills it. Add a 30-second polling join: if cleanup completes within 30s, join normally. Otherwise, log to stderr and return — the cleanup thread continues detached but the test process is no longer blocked. This is a stopgap. The proper fix (explicit .cleanup().await) is in follow-up commits. Refs #509 (same anti-pattern, different test set) --- server/tests/integration/helpers/mod.rs | 28 +++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/server/tests/integration/helpers/mod.rs b/server/tests/integration/helpers/mod.rs index e9df079b..f57e1bb0 100644 --- a/server/tests/integration/helpers/mod.rs +++ b/server/tests/integration/helpers/mod.rs @@ -168,7 +168,7 @@ impl Drop for CleanupGuard { } let pool = self.pool.clone(); - std::thread::spawn(move || { + let handle = std::thread::spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -178,9 +178,29 @@ impl Drop for CleanupGuard { action(pool.clone()).await; } }); - }) - .join() - .expect("Cleanup thread panicked"); + }); + + // Bounded join: poll for up to 30s, then detach if still running. + // Prevents flaky cleanup hangs from triggering nextest's 120s slow-timeout. + // Tradeoff: panics in the detached thread (timeout path) are silently + // discarded. Happy-path joins still propagate panics via .expect(). + // Removed once the explicit-cleanup migration completes (Task 14+). + let timeout = std::time::Duration::from_secs(30); + let start = std::time::Instant::now(); + loop { + if handle.is_finished() { + handle.join().expect("cleanup thread panicked"); + return; + } + if start.elapsed() > timeout { + eprintln!( + "warning: CleanupGuard did not complete within {timeout:?}, \ + detaching thread to allow test to finish" + ); + return; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } } } From 6efb39c78eff0ac9ec2a30b5ad71da1864dca8b5 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:21:23 +0200 Subject: [PATCH 02/19] feat(test): add CleanupGuard::cleanup() explicit method Tests should call .cleanup().await at the end of the body instead of relying on Drop. The new method consumes self, runs all cleanup actions on the caller's tokio runtime, and leaves Drop as a no-op. The Drop fallback now also prints a 'dropped with N pending actions' warning so the migration progress is visible. After all tests are migrated and CI is green for ~2 weeks, the warning will be replaced with a panic in a follow-up commit. Refs spec docs/superpowers/specs/2026-04-11-security-audit-followups-design.md --- server/tests/integration/helpers/mod.rs | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/server/tests/integration/helpers/mod.rs b/server/tests/integration/helpers/mod.rs index f57e1bb0..24f3f0fd 100644 --- a/server/tests/integration/helpers/mod.rs +++ b/server/tests/integration/helpers/mod.rs @@ -158,6 +158,26 @@ impl CleanupGuard { } }); } + + /// Run all registered cleanup actions on the caller's tokio runtime + /// and consume the guard. + /// + /// Tests MUST call this at the end of the test body. Forgetting it + /// triggers a runtime warning from the Drop fallback. The fallback + /// still runs the cleanup actions (so DB rows are not leaked), but + /// it does so on a fresh thread with a new tokio runtime — which + /// is the source of the original CI flake. After the migration + /// completes, the warning will be replaced with panic!() and any + /// forgotten cleanup will fail the test loudly. + pub async fn cleanup(mut self) { + let actions = std::mem::take(&mut self.actions); + let pool = self.pool.clone(); + for action in actions { + action(pool.clone()).await; + } + // Drop runs after this returns, but `actions` is now empty so + // Drop is a no-op. + } } impl Drop for CleanupGuard { @@ -167,6 +187,16 @@ impl Drop for CleanupGuard { return; } + // Migration signal: test forgot to call .cleanup().await. We still + // run cleanup so we don't leak DB rows, but the warning makes the + // unmigrated test visible. Replaced with panic!() in a follow-up + // commit after migration completes. + eprintln!( + "warning: CleanupGuard dropped with {} pending actions — \ + test forgot to call .cleanup().await", + actions.len() + ); + let pool = self.pool.clone(); let handle = std::thread::spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() From d4f49f3a61277d5fa83795e282923974531e519a Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:27:47 +0200 Subject: [PATCH 03/19] test(guild_limits): migrate to explicit CleanupGuard::cleanup().await 10 test sites migrated. Includes the 2 known-flaky tests that triggered the CleanupGuard CI fix: - test_globally_banned_user_cannot_join_via_discovery - test_channel_limit --- server/tests/integration/guild_limits.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/tests/integration/guild_limits.rs b/server/tests/integration/guild_limits.rs index 8a46a65d..df6b2d50 100644 --- a/server/tests/integration/guild_limits.rs +++ b/server/tests/integration/guild_limits.rs @@ -70,6 +70,7 @@ async fn test_guild_creation_limit() { let uuid = uuid::Uuid::parse_str(gid).unwrap(); delete_guild(&app.pool, uuid).await; } + guard.cleanup().await; } // ============================================================================ @@ -122,6 +123,7 @@ async fn test_member_limit_on_invite_join() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = body_to_json(resp).await; assert_eq!(body["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } // ============================================================================ @@ -186,6 +188,7 @@ async fn test_channel_limit() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = body_to_json(resp).await; assert_eq!(body["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } // ============================================================================ @@ -255,6 +258,7 @@ async fn test_role_limit() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = body_to_json(resp).await; assert_eq!(body["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } // ============================================================================ @@ -324,6 +328,7 @@ async fn test_bot_limit() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = body_to_json(resp).await; assert_eq!(body["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } // ============================================================================ @@ -371,6 +376,7 @@ async fn test_emoji_limit() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = body_to_json(resp).await; assert_eq!(body["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } // ============================================================================ @@ -420,6 +426,7 @@ async fn test_member_limit_on_discovery_join() { assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = body_to_json(resp).await; assert_eq!(body["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } #[tokio::test] @@ -466,6 +473,7 @@ async fn test_globally_banned_user_cannot_join_via_discovery() { assert_eq!(banned_resp.status(), StatusCode::FORBIDDEN); let body = body_to_json(banned_resp).await; assert_eq!(body["error"], "FORBIDDEN"); + guard.cleanup().await; } // ============================================================================ @@ -505,6 +513,7 @@ async fn test_usage_endpoint() { assert_eq!(body["channels"]["current"], 2); assert!(body["members"]["limit"].as_i64().unwrap() > 0); assert!(body["channels"]["limit"].as_i64().unwrap() > 0); + guard.cleanup().await; } #[tokio::test] @@ -530,6 +539,7 @@ async fn test_usage_requires_membership() { ) .await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); + guard.cleanup().await; } // ============================================================================ From dee105526113e411e714fca0de8248ade154a300 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:28:30 +0200 Subject: [PATCH 04/19] test(custom_status): migrate to explicit CleanupGuard::cleanup().await 6 test sites migrated. Includes the known-flaky test: - test_custom_status_with_expiry_persists - test_custom_status_clear_in_database --- server/tests/integration/custom_status.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/server/tests/integration/custom_status.rs b/server/tests/integration/custom_status.rs index 0bfca642..0d1476fd 100644 --- a/server/tests/integration/custom_status.rs +++ b/server/tests/integration/custom_status.rs @@ -222,6 +222,7 @@ async fn test_custom_status_set_in_database() { assert_eq!(deserialized.text, "Testing custom status"); assert_eq!(deserialized.emoji, Some("\u{1F9EA}".to_string())); assert!(deserialized.expires_at.is_none()); + guard.cleanup().await; } #[tokio::test] @@ -273,8 +274,7 @@ async fn test_custom_status_clear_in_database() { row.0.is_none(), "custom_status should be NULL after clearing" ); - - // guard drops here, runs cleanup even on panic + guard.cleanup().await; } #[tokio::test] @@ -320,8 +320,7 @@ async fn test_custom_status_with_expiry_persists() { diff <= 1, "Expiry time should be within 1 second of set value, diff was {diff}s" ); - - // guard drops here, runs cleanup even on panic + guard.cleanup().await; } // ============================================================================ @@ -389,8 +388,7 @@ async fn test_expiry_sweep_finds_expired_status() { row.0.is_none(), "custom_status should be NULL after sweep clears expired status" ); - - // guard drops here, runs cleanup even on panic + guard.cleanup().await; } #[tokio::test] @@ -446,8 +444,7 @@ async fn test_expiry_sweep_ignores_non_expired_status() { row.0.is_some(), "Non-expired custom_status should still be present" ); - - // guard drops here, runs cleanup even on panic + guard.cleanup().await; } #[tokio::test] @@ -490,6 +487,5 @@ async fn test_expiry_sweep_ignores_status_without_expiry() { !expired_ids.contains(&user_id), "Status without expiry should NOT be found by sweep query" ); - - // guard drops here, runs cleanup even on panic + guard.cleanup().await; } From fa38d14f589251c76619274bd716624786b66bce Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:32:40 +0200 Subject: [PATCH 05/19] test(workspaces): migrate to explicit CleanupGuard::cleanup().await 24 test sites migrated. --- server/tests/integration/workspaces.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/server/tests/integration/workspaces.rs b/server/tests/integration/workspaces.rs index dff60bc4..c6e5ee55 100644 --- a/server/tests/integration/workspaces.rs +++ b/server/tests/integration/workspaces.rs @@ -41,6 +41,7 @@ async fn test_create_workspace() { assert_eq!(json["name"], "Gaming Setup"); assert_eq!(json["icon"], "🎮"); assert!(json["id"].is_string()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -63,6 +64,7 @@ async fn test_create_workspace_name_too_long() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 400, "Should reject name > 100 chars"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -92,6 +94,7 @@ async fn test_create_workspace_icon_too_long() { let json = body_to_json(resp).await; assert_eq!(json["error"], "VALIDATION_ERROR"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -131,6 +134,7 @@ async fn test_create_workspace_unicode_name_length() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 400, "101 Unicode chars should be rejected"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -152,6 +156,7 @@ async fn test_create_workspace_empty_name() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 400, "Should reject empty/whitespace name"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -189,6 +194,7 @@ async fn test_create_workspace_limit_exceeded() { let json = body_to_json(resp).await; assert_eq!(json["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -226,6 +232,7 @@ async fn test_list_workspaces() { let arr = json.as_array().expect("Should be an array"); assert_eq!(arr.len(), 3, "Should have 3 workspaces"); assert!(arr[0]["entry_count"].is_number()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -248,6 +255,7 @@ async fn test_list_workspaces_empty() { let json = body_to_json(resp).await; let arr = json.as_array().expect("Should be an array"); assert!(arr.is_empty(), "Should be empty"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -302,6 +310,7 @@ async fn test_get_workspace_with_entries() { assert_eq!(entries.len(), 1); assert_eq!(entries[0]["channel_name"], "general"); assert!(entries[0]["guild_name"].is_string()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -321,6 +330,7 @@ async fn test_get_workspace_not_found() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 404); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -355,6 +365,7 @@ async fn test_get_workspace_not_owner() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 404, "Other user should get 404"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -394,6 +405,7 @@ async fn test_update_workspace() { let json = body_to_json(resp).await; assert_eq!(json["name"], "New Name"); assert_eq!(json["icon"], "🎯"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -459,6 +471,7 @@ async fn test_update_workspace_clear_icon() { let json = body_to_json(resp).await; assert_eq!(json["name"], "Renamed"); assert_eq!(json["icon"], "🔧", "Icon should be unchanged when omitted"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -495,6 +508,7 @@ async fn test_update_workspace_icon_too_long() { let json = body_to_json(resp).await; assert_eq!(json["error"], "VALIDATION_ERROR"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -533,6 +547,7 @@ async fn test_delete_workspace() { .unwrap(); let resp = app.oneshot(req).await; assert_eq!(resp.status(), 404, "Should be gone"); + guard.cleanup().await; } // ============================================================================ @@ -580,6 +595,7 @@ async fn test_add_entry() { let json = body_to_json(resp).await; assert_eq!(json["channel_name"], "raids"); assert!(json["guild_name"].is_string()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -628,6 +644,7 @@ async fn test_add_entry_duplicate() { .unwrap(); let resp = app.oneshot(req).await; assert_eq!(resp.status(), 409, "Should reject duplicate entry"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -671,6 +688,7 @@ async fn test_add_entry_no_guild_membership() { .unwrap(); let resp = app.oneshot(req).await; assert_eq!(resp.status(), 404, "Should reject non-member"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -729,6 +747,7 @@ async fn test_add_entry_limit_exceeded() { let json = body_to_json(resp).await; assert_eq!(json["error"], "LIMIT_EXCEEDED"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -790,6 +809,7 @@ async fn test_remove_entry() { let json = body_to_json(resp).await; let entries = json["entries"].as_array().unwrap(); assert!(entries.is_empty(), "Should have no entries after removal"); + guard.cleanup().await; } // ============================================================================ @@ -865,6 +885,7 @@ async fn test_reorder_entries() { let entries = json["entries"].as_array().unwrap(); assert_eq!(entries[0]["channel_name"], "ch-two"); assert_eq!(entries[1]["channel_name"], "ch-one"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -914,6 +935,7 @@ async fn test_reorder_workspaces() { assert_eq!(arr[0]["name"], "Third"); assert_eq!(arr[1]["name"], "Second"); assert_eq!(arr[2]["name"], "First"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -945,6 +967,7 @@ async fn test_reorder_entries_rejects_oversized_payload() { let json = body_to_json(resp).await; assert_eq!(json["error"], "VALIDATION_ERROR"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -973,4 +996,5 @@ async fn test_reorder_workspaces_rejects_oversized_payload() { let json = body_to_json(resp).await; assert_eq!(json["error"], "VALIDATION_ERROR"); + guard.cleanup().await; } From 5117911e268c841b08df10f2489633e1ba68dbbe Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:33:49 +0200 Subject: [PATCH 06/19] test(filters_http): migrate to explicit CleanupGuard::cleanup().await 17 test sites migrated. --- server/tests/integration/filters_http.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/tests/integration/filters_http.rs b/server/tests/integration/filters_http.rs index cbbafc9f..81cb0ec8 100644 --- a/server/tests/integration/filters_http.rs +++ b/server/tests/integration/filters_http.rs @@ -165,6 +165,7 @@ async fn test_list_filter_configs_empty() { assert_eq!(resp.status(), 200); let json = body_to_json(resp).await; assert!(json.as_array().unwrap().is_empty()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -191,6 +192,7 @@ async fn test_enable_and_list_filter_category() { assert_eq!(configs[0]["category"], "spam"); assert_eq!(configs[0]["enabled"], true); assert_eq!(configs[0]["action"], "block"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -218,6 +220,7 @@ async fn test_disable_filter_category() { assert_eq!(resp.status(), 200); let json = body_to_json(resp).await; assert_eq!(json[0]["enabled"], false); + guard.cleanup().await; } // ============================================================================ @@ -237,6 +240,7 @@ async fn test_create_custom_keyword_pattern() { assert_eq!(pattern["pattern"], "badword"); assert_eq!(pattern["is_regex"], false); assert_eq!(pattern["enabled"], true); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -250,6 +254,7 @@ async fn test_create_custom_regex_pattern() { let pattern = create_pattern(&app, guild_id, &token, r"(?i)bad\s+word", true).await; assert_eq!(pattern["is_regex"], true); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -276,6 +281,7 @@ async fn test_invalid_regex_rejected() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 400, "Invalid regex should be rejected"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -300,6 +306,7 @@ async fn test_delete_custom_pattern() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 204, "Expected 204 No Content for delete"); + guard.cleanup().await; } // ============================================================================ @@ -323,6 +330,7 @@ async fn test_message_blocked_by_custom_keyword() { send_message_raw(&app, channel_id, &token, "this is forbidden content").await; assert_eq!(status, 403, "Blocked message should return 403"); assert_eq!(json["error"], "CONTENT_FILTERED"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -340,6 +348,7 @@ async fn test_clean_message_allowed() { // Send clean message let (status, _) = send_message_raw(&app, channel_id, &token, "this is perfectly fine").await; assert_eq!(status, 201, "Clean message should be allowed"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -366,6 +375,7 @@ async fn test_edit_blocked_by_filter() { "Edited message with blocked word should return 403" ); assert_eq!(json["error"], "CONTENT_FILTERED"); + guard.cleanup().await; } // ============================================================================ @@ -390,6 +400,7 @@ async fn test_encrypted_message_not_filtered() { status, 201, "Encrypted message should bypass content filter" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -409,6 +420,7 @@ async fn test_dm_message_not_filtered() { // DMs have no guild_id, so filters don't apply let (status, _) = send_message_raw(&app, dm_channel, &token_a, "anything goes in DMs").await; assert_eq!(status, 201, "DM messages should not be filtered"); + guard.cleanup().await; } // ============================================================================ @@ -450,6 +462,7 @@ async fn test_log_action_allows_message_but_creates_log() { json["total"].as_i64().unwrap() > 0, "Moderation log should have entries from log action" ); + guard.cleanup().await; } // ============================================================================ @@ -484,6 +497,7 @@ async fn test_moderation_log_pagination() { let json = body_to_json(resp).await; assert_eq!(json["items"].as_array().unwrap().len(), 2); assert!(json["total"].as_i64().unwrap() >= 3); + guard.cleanup().await; } // ============================================================================ @@ -514,6 +528,7 @@ async fn test_non_admin_cannot_modify_filters() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 403, "Non-admin should get 403"); + guard.cleanup().await; } // ============================================================================ @@ -565,6 +580,7 @@ async fn test_filter_dry_run() { let json = body_to_json(resp).await; assert_eq!(json["blocked"], false); assert!(json["matches"].as_array().unwrap().is_empty()); + guard.cleanup().await; } // ============================================================================ @@ -594,4 +610,5 @@ async fn test_cache_invalidation_on_config_change() { "After adding filter, message should be blocked" ); assert_eq!(json["error"], "CONTENT_FILTERED"); + guard.cleanup().await; } From 1ace052cda16b67d83a63c6d991d2ead39a921e0 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:34:36 +0200 Subject: [PATCH 07/19] test(governance): migrate to explicit CleanupGuard::cleanup().await 12 test sites migrated. --- server/tests/integration/governance.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/tests/integration/governance.rs b/server/tests/integration/governance.rs index 6dfa3872..6b80e91d 100644 --- a/server/tests/integration/governance.rs +++ b/server/tests/integration/governance.rs @@ -55,6 +55,7 @@ async fn test_request_data_export_no_s3() { 503, "Should return 503 when S3 is not configured" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -77,6 +78,7 @@ async fn test_get_export_status_none() { 404, "Should return 404 when no export job exists" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -118,6 +120,7 @@ async fn test_request_export_recovers_stale_pending_job() { .await .unwrap(); assert_eq!(status.as_deref(), Some("failed")); + guard.cleanup().await; } // ============================================================================ @@ -152,6 +155,7 @@ async fn test_request_deletion_requires_confirm() { 400, "Should reject wrong confirmation string" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -177,6 +181,7 @@ async fn test_request_deletion_requires_password() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 400, "Should require password for local auth"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -202,6 +207,7 @@ async fn test_request_deletion_wrong_password() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 401, "Should reject wrong password"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -237,6 +243,7 @@ async fn test_request_deletion_success() { json["message"].as_str().unwrap().contains("scheduled"), "Response should contain scheduling message" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -281,6 +288,7 @@ async fn test_deletion_blocked_by_guild_ownership() { json["message"].as_str().unwrap().contains("guilds"), "Message should mention guilds" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -325,6 +333,7 @@ async fn test_cancel_deletion() { json["message"].as_str().unwrap().contains("cancelled"), "Response should confirm cancellation" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -347,6 +356,7 @@ async fn test_cancel_deletion_when_not_pending() { 404, "Should return 404 when no deletion is pending" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -388,6 +398,7 @@ async fn test_duplicate_deletion_request() { 409, "Should reject duplicate deletion request" ); + guard.cleanup().await; } // ============================================================================ @@ -434,4 +445,5 @@ async fn test_profile_shows_deletion_scheduled() { json["deletion_scheduled_at"].is_string(), "Profile should include deletion_scheduled_at after deletion request" ); + guard.cleanup().await; } From 76126b57d6db6702688021fbb893f81b3163bfd7 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:35:22 +0200 Subject: [PATCH 08/19] test(webhooks): migrate to explicit CleanupGuard::cleanup().await 10 test sites migrated. --- server/tests/integration/webhooks.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/tests/integration/webhooks.rs b/server/tests/integration/webhooks.rs index 0438e8cc..a9dd7350 100644 --- a/server/tests/integration/webhooks.rs +++ b/server/tests/integration/webhooks.rs @@ -48,6 +48,7 @@ async fn create_webhook_returns_signing_secret() { assert_eq!(json["signing_secret"].as_str().unwrap().len(), 64); assert_eq!(json["url"], "https://example.com/webhook"); assert_eq!(json["active"], true); + guard.cleanup().await; } #[tokio::test] @@ -80,6 +81,7 @@ async fn list_webhooks_does_not_return_secret() { let list = json.as_array().unwrap(); assert_eq!(list.len(), 1); assert!(list[0].get("signing_secret").is_none()); + guard.cleanup().await; } #[tokio::test] @@ -113,6 +115,7 @@ async fn get_webhook_returns_details() { let json = body_to_json(resp).await; assert_eq!(json["url"], "https://example.com/wh"); assert_eq!(json["active"], true); + guard.cleanup().await; } #[tokio::test] @@ -153,6 +156,7 @@ async fn update_webhook_url_and_events() { let json = body_to_json(resp).await; assert_eq!(json["url"], "https://example.com/new-url"); assert_eq!(json["active"], false); + guard.cleanup().await; } #[tokio::test] @@ -182,6 +186,7 @@ async fn delete_webhook_succeeds() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), StatusCode::NO_CONTENT); + guard.cleanup().await; } // ============================================================================ @@ -215,6 +220,7 @@ async fn non_owner_cannot_manage_webhooks() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); + guard.cleanup().await; } // ============================================================================ @@ -246,6 +252,7 @@ async fn invalid_url_rejected() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + guard.cleanup().await; } #[tokio::test] @@ -273,6 +280,7 @@ async fn empty_events_rejected() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + guard.cleanup().await; } #[tokio::test] @@ -312,6 +320,7 @@ async fn max_5_webhooks_enforced() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), StatusCode::CONFLICT); + guard.cleanup().await; } // ============================================================================ @@ -348,4 +357,5 @@ async fn delivery_log_initially_empty() { let json = body_to_json(resp).await; assert_eq!(json.as_array().unwrap().len(), 0); + guard.cleanup().await; } From 51ef7044ccafcb4e36d72f7a19660cf2413414d1 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:36:08 +0200 Subject: [PATCH 09/19] test(channel_pins): migrate to explicit CleanupGuard::cleanup().await 10 test sites migrated. --- server/tests/integration/channel_pins.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/tests/integration/channel_pins.rs b/server/tests/integration/channel_pins.rs index b5ba0555..fe81fdbd 100644 --- a/server/tests/integration/channel_pins.rs +++ b/server/tests/integration/channel_pins.rs @@ -119,6 +119,7 @@ async fn test_pin_message_success() { pins_arr[0]["pinned_at"].is_string(), "Should include pinned_at" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -147,6 +148,7 @@ async fn test_unpin_message_success() { let pins = list_pins(&app, channel_id, &token).await; let pins_arr = pins.as_array().expect("pins should be an array"); assert!(pins_arr.is_empty(), "Pins list should be empty after unpin"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -178,6 +180,7 @@ async fn test_pin_idempotent() { 1, "Should have exactly one pin despite pinning twice" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -214,6 +217,7 @@ async fn test_pin_limit_50() { "PIN_LIMIT_REACHED", "Error code should be PIN_LIMIT_REACHED" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -252,6 +256,7 @@ async fn test_pin_forbidden_without_permission() { 200, "Owner should succeed regardless of @everyone role permissions" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -277,6 +282,7 @@ async fn test_pin_message_not_in_channel() { 404, "Pinning a message in the wrong channel should return 404" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -301,6 +307,7 @@ async fn test_pin_deleted_message() { 404, "Pinning a deleted message should return 404" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -339,6 +346,7 @@ async fn test_system_message_on_pin() { content.contains("pinned a message"), "System message should contain 'pinned a message', got: {content}" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -381,6 +389,7 @@ async fn test_pinned_field_in_message_list() { assert!(!pinned, "Non-pinned message should have pinned=false"); } } + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -421,4 +430,5 @@ async fn test_cascade_on_message_delete() { pins_arr.is_empty(), "Pins list should be empty after message deletion" ); + guard.cleanup().await; } From 28a2f3d20761ca5d246fd4c1c9f801f069a4fdd6 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:38:20 +0200 Subject: [PATCH 10/19] test(api): migrate connectivity_http to explicit CleanupGuard::cleanup().await 8 test sites migrated. --- server/tests/integration/connectivity_http.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/tests/integration/connectivity_http.rs b/server/tests/integration/connectivity_http.rs index 8501b148..684f30ad 100644 --- a/server/tests/integration/connectivity_http.rs +++ b/server/tests/integration/connectivity_http.rs @@ -99,6 +99,7 @@ async fn test_summary_empty() { assert_eq!(json["total_sessions"], 0); assert_eq!(json["total_duration_secs"], 0); assert!(json["daily_stats"].as_array().unwrap().is_empty()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -144,6 +145,7 @@ async fn test_summary_with_data() { assert!(json["total_duration_secs"].as_i64().unwrap() > 0); assert!(json["avg_latency"].is_number()); assert!(!json["daily_stats"].as_array().unwrap().is_empty()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -182,6 +184,7 @@ async fn test_sessions_empty() { let json = body_to_json(resp).await; assert_eq!(json["total"], 0); assert!(json["sessions"].as_array().unwrap().is_empty()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -219,6 +222,7 @@ async fn test_sessions_with_data() { assert_eq!(sessions[0]["id"], session_id.to_string()); assert_eq!(sessions[0]["channel_name"], "voice-sess"); assert!(sessions[0]["guild_name"].is_string()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -262,6 +266,7 @@ async fn test_sessions_pagination() { 1, "Should return exactly 1 session" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -322,6 +327,7 @@ async fn test_session_detail() { assert!(metrics[0]["packet_loss"].is_number()); assert!(metrics[0]["jitter_ms"].is_number()); assert!(metrics[0]["quality"].is_number()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -342,6 +348,7 @@ async fn test_session_detail_not_found() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), 404, "Non-existent session should return 404"); + guard.cleanup().await; } // ============================================================================ @@ -386,4 +393,5 @@ async fn test_session_rls_isolation() { let json = body_to_json(resp).await; assert_eq!(json["total"], 0, "User B should not see User A's sessions"); assert!(json["sessions"].as_array().unwrap().is_empty()); + guard.cleanup().await; } From e1059ba48d9cceff50b9db59ebf0389cc785d276 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:38:59 +0200 Subject: [PATCH 11/19] test(api): migrate setup_http to explicit CleanupGuard::cleanup().await 6 test sites migrated. --- server/tests/integration/setup_http.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/tests/integration/setup_http.rs b/server/tests/integration/setup_http.rs index fa3d094d..02bd172b 100644 --- a/server/tests/integration/setup_http.rs +++ b/server/tests/integration/setup_http.rs @@ -98,6 +98,7 @@ async fn test_config_returns_values_when_setup_incomplete() { json["privacy_url"].is_null() || json["privacy_url"].is_string(), "Expected privacy_url to be null or string" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -118,6 +119,7 @@ async fn test_config_returns_403_when_setup_complete() { let json = body_to_json(resp).await; assert_eq!(json["error"], "SETUP_ALREADY_COMPLETE"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -173,6 +175,7 @@ async fn test_complete_requires_admin() { let json = body_to_json(resp).await; assert_eq!(json["error"], "FORBIDDEN"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -218,6 +221,7 @@ async fn test_complete_succeeds_for_admin() { Some(true), "setup_complete should be true after completion" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -251,6 +255,7 @@ async fn test_complete_rejects_invalid_body() { let json = body_to_json(resp).await; assert_eq!(json["error"], "VALIDATION_ERROR"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -286,4 +291,5 @@ async fn test_complete_already_done() { let json = body_to_json(resp).await; assert_eq!(json["error"], "SETUP_ALREADY_COMPLETE"); + guard.cleanup().await; } From d97d13766b842588a45262c26da7a12eb5d2c49c Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:39:34 +0200 Subject: [PATCH 12/19] test(chat): migrate messages_http to explicit CleanupGuard::cleanup().await 6 test sites migrated. --- server/tests/integration/messages_http.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/tests/integration/messages_http.rs b/server/tests/integration/messages_http.rs index ae0ae02f..ea8323bb 100644 --- a/server/tests/integration/messages_http.rs +++ b/server/tests/integration/messages_http.rs @@ -85,6 +85,7 @@ async fn test_create_message_success() { msg["created_at"].is_string(), "Response should have created_at" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -135,6 +136,7 @@ async fn test_create_message_validation_errors() { 400, "Encrypted message without nonce should return 400" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -191,6 +193,7 @@ async fn test_list_messages_pagination() { "Page 2 should not contain items from page 1" ); } + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -244,6 +247,7 @@ async fn test_edit_message_owner_only() { edited["edited_at"].is_string(), "edited_at should be set after edit" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -289,6 +293,7 @@ async fn test_delete_message_owner_only() { let items = msgs["items"].as_array().unwrap(); let found = items.iter().any(|m| m["id"].as_str() == Some(msg_id)); assert!(!found, "Deleted message should not appear in listing"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -320,4 +325,5 @@ async fn test_create_message_nonexistent_channel() { 404, "Posting to nonexistent channel should return 404" ); + guard.cleanup().await; } From 98b8ddff92c3b5f4d2d636630b196911b6aeada5 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:40:10 +0200 Subject: [PATCH 13/19] test(api): migrate media_processing to explicit CleanupGuard::cleanup().await 6 test sites migrated. --- server/tests/integration/media_processing.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/tests/integration/media_processing.rs b/server/tests/integration/media_processing.rs index b488d769..ea82441d 100644 --- a/server/tests/integration/media_processing.rs +++ b/server/tests/integration/media_processing.rs @@ -105,6 +105,7 @@ async fn test_variant_download_not_found() { "Variant download for non-existent attachment should return 403, 404, or 503 (no S3), got {}", resp.status() ); + guard.cleanup().await; } // ============================================================================ @@ -172,6 +173,7 @@ async fn test_image_upload_generates_metadata() { attachment["medium_url"].is_null(), "Should not have medium_url for 500px image" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -225,6 +227,7 @@ async fn test_non_image_upload_no_metadata() { attachment["thumbnail_url"].is_null(), "Text file should not have thumbnail_url" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -291,6 +294,7 @@ async fn test_variant_download_returns_webp() { !thumb_bytes.is_empty(), "Thumbnail data should not be empty" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -356,6 +360,7 @@ async fn test_variant_fallback_to_original() { "image/png", "Fallback should serve original PNG content type" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -422,4 +427,5 @@ async fn test_invalid_variant_returns_validation_error() { "Unexpected validation message: {}", error ); + guard.cleanup().await; } From 92bd00fec36bb19620b92d01e6f9fed1c6eb7a37 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:40:45 +0200 Subject: [PATCH 14/19] test(api): migrate bot_intents to explicit CleanupGuard::cleanup().await 6 test sites migrated. --- server/tests/integration/bot_intents.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/tests/integration/bot_intents.rs b/server/tests/integration/bot_intents.rs index cf06b837..f5e74bdc 100644 --- a/server/tests/integration/bot_intents.rs +++ b/server/tests/integration/bot_intents.rs @@ -37,6 +37,7 @@ async fn update_intents_persists() { assert!(intents.iter().any(|v| v == "messages")); assert!(intents.iter().any(|v| v == "members")); assert!(intents.iter().any(|v| v == "commands")); + guard.cleanup().await; } #[tokio::test] @@ -73,6 +74,7 @@ async fn update_intents_reflects_in_get() { assert_eq!(intents.len(), 2); assert!(intents.iter().any(|v| v == "messages")); assert!(intents.iter().any(|v| v == "members")); + guard.cleanup().await; } // ============================================================================ @@ -100,6 +102,7 @@ async fn invalid_intent_name_rejected() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + guard.cleanup().await; } #[tokio::test] @@ -125,6 +128,7 @@ async fn empty_intents_allowed() { let json = body_to_json(resp).await; let intents = json["gateway_intents"].as_array().unwrap(); assert!(intents.is_empty()); + guard.cleanup().await; } // ============================================================================ @@ -152,6 +156,7 @@ async fn non_owner_cannot_update_intents() { let resp = app.oneshot(req).await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); + guard.cleanup().await; } // ============================================================================ @@ -185,6 +190,7 @@ async fn new_application_has_default_intents() { // New applications should have empty gateway_intents by default (from DB default) let intents = json["gateway_intents"].as_array().unwrap(); assert!(intents.is_empty()); + guard.cleanup().await; } // ============================================================================ From adfdd9bfe021d27357d57d683da986a19ec2c8c3 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:41:19 +0200 Subject: [PATCH 15/19] test(chat): migrate dm_http to explicit CleanupGuard::cleanup().await 5 test sites migrated. --- server/tests/integration/dm_http.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/tests/integration/dm_http.rs b/server/tests/integration/dm_http.rs index 6945f4cd..4d6836f4 100644 --- a/server/tests/integration/dm_http.rs +++ b/server/tests/integration/dm_http.rs @@ -84,6 +84,7 @@ async fn test_create_and_get_dm() { let fetched = body_to_json(resp).await; assert_eq!(fetched["id"].as_str().unwrap(), dm_id); assert_eq!(fetched["channel_type"], "dm"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -114,6 +115,7 @@ async fn test_create_dm_returns_existing() { dm_id1, dm_id2, "Creating DM twice with same participants should return same channel" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -151,6 +153,7 @@ async fn test_list_dms() { let dms_b = list_dms(&app, &token_b).await; let arr_b = dms_b.as_array().expect("DM list should be an array"); assert_eq!(arr_b.len(), 1, "User B should see 1 DM"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -184,6 +187,7 @@ async fn test_dm_non_participant_forbidden() { 403, "Non-participant should get 403 when accessing DM" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -222,4 +226,5 @@ async fn test_leave_dm() { "After leaving, GET DM should return 403 or 404, got {}", resp.status() ); + guard.cleanup().await; } From 8d073440897cbbd6e736a09df460bc71a5d9394e Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:41:54 +0200 Subject: [PATCH 16/19] test(api): migrate channels_http to explicit CleanupGuard::cleanup().await 5 test sites migrated. --- server/tests/integration/channels_http.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/tests/integration/channels_http.rs b/server/tests/integration/channels_http.rs index 8e934788..8659437a 100644 --- a/server/tests/integration/channels_http.rs +++ b/server/tests/integration/channels_http.rs @@ -47,6 +47,7 @@ async fn test_create_channel_success() { assert_eq!(json["name"], "new-channel"); assert_eq!(json["channel_type"], "text"); assert_eq!(json["guild_id"], guild_id.to_string()); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -107,6 +108,7 @@ async fn test_create_channel_validation_errors() { 400, "Voice channel with user_limit=100 should return 400" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -155,6 +157,7 @@ async fn test_update_channel_requires_manage_channels() { let json = body_to_json(resp).await; assert_eq!(json["name"], "renamed-by-owner"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -195,6 +198,7 @@ async fn test_delete_channel_requires_manage_channels() { .unwrap(); let resp = app.oneshot(req).await; assert_eq!(resp.status(), 204, "Owner should be able to delete"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -220,4 +224,5 @@ async fn test_get_channel_not_found() { "Non-existent channel should return 403 or 404, got {}", resp.status() ); + guard.cleanup().await; } From 082218c7366c0e1bbcc14187c0c19109be135fcb Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:42:22 +0200 Subject: [PATCH 17/19] test(api): migrate uploads_http to explicit CleanupGuard::cleanup().await 3 test sites migrated. --- server/tests/integration/uploads_http.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/tests/integration/uploads_http.rs b/server/tests/integration/uploads_http.rs index dc05bdd7..e845ceed 100644 --- a/server/tests/integration/uploads_http.rs +++ b/server/tests/integration/uploads_http.rs @@ -53,6 +53,7 @@ async fn test_upload_returns_503_without_s3() { 503, "Upload without S3 should return 503 Service Unavailable" ); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -103,6 +104,7 @@ async fn test_get_attachment_not_found() { ); let body = body_to_json(resp).await; assert_eq!(body["error"], "FORBIDDEN"); + guard.cleanup().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -178,4 +180,5 @@ async fn test_get_attachment_anti_enumeration_parity() { ); let missing_body = body_to_json(missing_resp).await; assert_eq!(missing_body["error"], "FORBIDDEN"); + guard.cleanup().await; } From df217abe5e9d93c49a135a791d0ede0a05931b4f Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:42:44 +0200 Subject: [PATCH 18/19] test(api): migrate setup_concurrent_http to explicit CleanupGuard::cleanup().await 2 test sites migrated. --- server/tests/integration/setup_concurrent_http.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/tests/integration/setup_concurrent_http.rs b/server/tests/integration/setup_concurrent_http.rs index 4398c2a7..f187fb87 100644 --- a/server/tests/integration/setup_concurrent_http.rs +++ b/server/tests/integration/setup_concurrent_http.rs @@ -126,6 +126,7 @@ async fn test_concurrent_http_setup_completion() { Some(true), "setup_complete should be true after concurrent completion" ); + guard.cleanup().await; } /// Test that concurrent completion with 5 admins still results in exactly one success. @@ -200,4 +201,5 @@ async fn test_concurrent_http_setup_five_admins() { num_admins - 1, "All other admins should get 403" ); + guard.cleanup().await; } From 81b993d1c8a8af491b06e25c869890a5c5d46e0d Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 11 Apr 2026 21:47:51 +0200 Subject: [PATCH 19/19] docs(test): clarify CleanupGuard Drop is a permanent safety net Co-Authored-By: Claude Sonnet 4.6 --- server/tests/integration/helpers/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/tests/integration/helpers/mod.rs b/server/tests/integration/helpers/mod.rs index 24f3f0fd..6b79264a 100644 --- a/server/tests/integration/helpers/mod.rs +++ b/server/tests/integration/helpers/mod.rs @@ -214,7 +214,9 @@ impl Drop for CleanupGuard { // Prevents flaky cleanup hangs from triggering nextest's 120s slow-timeout. // Tradeoff: panics in the detached thread (timeout path) are silently // discarded. Happy-path joins still propagate panics via .expect(). - // Removed once the explicit-cleanup migration completes (Task 14+). + // Kept as a safety net for tests that panic before reaching + // .cleanup().await (RAII semantics). Removed only when the Drop + // fallback itself becomes panic!() (Layer 3 follow-up). let timeout = std::time::Duration::from_secs(30); let start = std::time::Instant::now(); loop {