Skip to main content

max / makenotwork

106.4 KB · 3321 lines History Blame Raw
1 use std::collections::HashMap;
2 use std::str::FromStr;
3
4 use axum::body::Body;
5 use http_body_util::BodyExt;
6 use tower::ServiceExt;
7
8 use pom::db;
9 use pom::tools::PomServer;
10 use pom::types::*;
11
12 #[tokio::test]
13 async fn health_check_insert_and_query() {
14 let pool = db::connect_in_memory().await.unwrap();
15
16 let snapshot = HealthSnapshot {
17 id: None,
18 target: "test-target".to_string(),
19 status: HealthStatus::Operational,
20 checked_at: "2026-03-10T00:00:00Z".to_string(),
21 response_time_ms: 150,
22 details: Some(HealthDetails {
23 version: Some("1.0.0".to_string()),
24 uptime: Some("5h 30m".to_string()),
25 checks: None,
26 monitoring: None,
27 }),
28 error: None,
29 };
30
31 let id = db::insert_health_check(&pool, &snapshot).await.unwrap();
32 assert!(id > 0);
33
34 let latest = db::get_latest_health(&pool, "test-target").await.unwrap();
35 assert!(latest.is_some());
36 let latest = latest.unwrap();
37 assert_eq!(latest.status, HealthStatus::Operational);
38 assert_eq!(latest.response_time_ms, 150);
39 assert_eq!(latest.details.unwrap().version.unwrap(), "1.0.0");
40 }
41
42 #[tokio::test]
43 async fn health_history_returns_ordered() {
44 let pool = db::connect_in_memory().await.unwrap();
45
46 for i in 0..5 {
47 let snapshot = HealthSnapshot {
48 id: None,
49 target: "mnw".to_string(),
50 status: HealthStatus::Operational,
51 checked_at: format!("2026-03-10T0{}:00:00Z", i),
52 response_time_ms: 100 + i * 10,
53 details: None,
54 error: None,
55 };
56 db::insert_health_check(&pool, &snapshot).await.unwrap();
57 }
58
59 let history = db::get_health_history(&pool, Some("mnw"), 3).await.unwrap();
60 assert_eq!(history.len(), 3);
61 // Most recent first (DESC)
62 assert!(history[0].response_time_ms > history[1].response_time_ms);
63 }
64
65 #[tokio::test]
66 async fn health_history_filters_by_target() {
67 let pool = db::connect_in_memory().await.unwrap();
68
69 for target in &["alpha", "beta"] {
70 let snapshot = HealthSnapshot {
71 id: None,
72 target: target.to_string(),
73 status: HealthStatus::Operational,
74 checked_at: "2026-03-10T00:00:00Z".to_string(),
75 response_time_ms: 100,
76 details: None,
77 error: None,
78 };
79 db::insert_health_check(&pool, &snapshot).await.unwrap();
80 }
81
82 let all = db::get_health_history(&pool, None, 10).await.unwrap();
83 assert_eq!(all.len(), 2);
84
85 let alpha_only = db::get_health_history(&pool, Some("alpha"), 10).await.unwrap();
86 assert_eq!(alpha_only.len(), 1);
87 assert_eq!(alpha_only[0].target, "alpha");
88 }
89
90 #[tokio::test]
91 async fn test_run_insert_and_query() {
92 let pool = db::connect_in_memory().await.unwrap();
93
94 let run = TestRun {
95 id: None,
96 target: "mnw".to_string(),
97 started_at: "2026-03-10T00:00:00Z".to_string(),
98 finished_at: Some("2026-03-10T00:02:00Z".to_string()),
99 duration_secs: Some(120),
100 exit_code: Some(0),
101 passed: true,
102 summary: TestSummary {
103 steps: vec![
104 StepResult { name: "cargo check".to_string(), passed: true },
105 StepResult { name: "cargo test --lib".to_string(), passed: true },
106 ],
107 total_passed: Some(759),
108 total_failed: Some(0),
109 details: vec![],
110 },
111 raw_output: "test output here".to_string(),
112 filter: None,
113 };
114
115 let id = db::insert_test_run(&pool, &run).await.unwrap();
116 assert!(id.0 > 0);
117
118 let latest = db::get_latest_test_run(&pool, "mnw").await.unwrap();
119 assert!(latest.is_some());
120 let latest = latest.unwrap();
121 assert!(latest.passed);
122 assert_eq!(latest.summary.total_passed, Some(759));
123 assert_eq!(latest.summary.steps.len(), 2);
124 assert_eq!(latest.raw_output, "test output here");
125 }
126
127 #[tokio::test]
128 async fn test_history_excludes_other_targets() {
129 let pool = db::connect_in_memory().await.unwrap();
130
131 for target in &["mnw", "other"] {
132 let run = TestRun {
133 id: None,
134 target: target.to_string(),
135 started_at: "2026-03-10T00:00:00Z".to_string(),
136 finished_at: None,
137 duration_secs: None,
138 exit_code: None,
139 passed: true,
140 summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![] },
141 raw_output: String::new(),
142 filter: None,
143 };
144 db::insert_test_run(&pool, &run).await.unwrap();
145 }
146
147 let mnw_only = db::get_test_history(&pool, Some("mnw"), 10).await.unwrap();
148 assert_eq!(mnw_only.len(), 1);
149 }
150
151 #[tokio::test]
152 async fn prune_removes_old_records() {
153 let pool = db::connect_in_memory().await.unwrap();
154
155 // Insert an old health check (60 days ago)
156 let old = HealthSnapshot {
157 id: None,
158 target: "mnw".to_string(),
159 status: HealthStatus::Operational,
160 checked_at: (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339(),
161 response_time_ms: 100,
162 details: None,
163 error: None,
164 };
165 db::insert_health_check(&pool, &old).await.unwrap();
166
167 // Insert a recent one
168 let recent = HealthSnapshot {
169 id: None,
170 target: "mnw".to_string(),
171 status: HealthStatus::Operational,
172 checked_at: chrono::Utc::now().to_rfc3339(),
173 response_time_ms: 100,
174 details: None,
175 error: None,
176 };
177 db::insert_health_check(&pool, &recent).await.unwrap();
178
179 let result = db::prune_old_records(&pool, 30).await.unwrap();
180 assert_eq!(result.health, 1);
181
182 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
183 assert_eq!(remaining.len(), 1);
184 }
185
186 #[tokio::test]
187 async fn parse_ci_output_integration() {
188 use pom::checks::parse;
189
190 let output = r#"
191 ========================================
192 cargo check
193 ========================================
194
195 Finished `dev` profile
196
197 ========================================
198 cargo test --lib
199 ========================================
200
201 running 45 tests
202 test result: ok. 45 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.3s
203
204 ========================================
205 CI Summary
206 ========================================
207
208 PASS cargo check
209 PASS cargo test --lib
210 PASS cargo clippy
211
212 All steps passed.
213 "#;
214
215 let summary = parse::parse_ci_output(output);
216 assert_eq!(summary.steps.len(), 3);
217 assert!(summary.steps.iter().all(|s| s.passed));
218 assert_eq!(summary.total_passed, Some(45));
219 assert_eq!(summary.total_failed, Some(0));
220 }
221
222 #[tokio::test]
223 async fn peer_identity_first_wins() {
224 let pool = db::connect_in_memory().await.unwrap();
225
226 db::store_peer_identity(&pool, "astra", "uuid-1").await.unwrap();
227 // Second insert with different ID should be ignored (INSERT OR IGNORE)
228 db::store_peer_identity(&pool, "astra", "uuid-2").await.unwrap();
229
230 let stored = db::get_peer_identity(&pool, "astra").await.unwrap();
231 assert_eq!(stored, Some("uuid-1".to_string()));
232 }
233
234 #[tokio::test]
235 async fn peer_heartbeat_insert_and_query() {
236 let pool = db::connect_in_memory().await.unwrap();
237
238 db::insert_peer_heartbeat(&pool, "astra", "online", 42).await.unwrap();
239 db::insert_peer_heartbeat(&pool, "astra", "online", 55).await.unwrap();
240 db::insert_peer_heartbeat(&pool, "astra", "missing", 0).await.unwrap();
241
242 let history = db::get_peer_heartbeat_history(&pool, "astra", 10).await.unwrap();
243 assert_eq!(history.len(), 3);
244 // Most recent first
245 assert_eq!(history[0].status, "missing");
246 assert_eq!(history[1].latency_ms, 55);
247 }
248
249 // --- API endpoint tests ---
250
251 fn test_config() -> pom::config::Config {
252 toml::from_str(
253 r#"
254 [targets.mnw]
255 label = "MakeNotWork"
256 [targets.mnw.health]
257 url = "https://makenot.work/health"
258 "#,
259 )
260 .unwrap()
261 }
262
263 /// GET a path and return (status_code, body_string) — for HTML responses.
264 async fn get_body(app: &axum::Router, path: &str) -> (u16, String) {
265 let req = axum::http::Request::builder()
266 .uri(path)
267 .body(Body::empty())
268 .unwrap();
269 let resp = app.clone().oneshot(req).await.unwrap();
270 let status = resp.status().as_u16();
271 let body = resp.into_body().collect().await.unwrap().to_bytes();
272 (status, String::from_utf8_lossy(&body).into_owned())
273 }
274
275 fn test_mesh() -> pom::peer::SharedMeshState {
276 let info = pom::peer::InstanceInfo {
277 id: "test-uuid".to_string(),
278 name: "test-node".to_string(),
279 version: "0.1.0".to_string(),
280 targets: vec!["mnw".to_string()],
281 started_at: "2026-03-10T00:00:00Z".to_string(),
282 };
283 pom::peer::new_mesh_state(info, &HashMap::new())
284 }
285
286 async fn api_get(app: &axum::Router, path: &str) -> (u16, serde_json::Value) {
287 let req = axum::http::Request::builder()
288 .uri(path)
289 .body(Body::empty())
290 .unwrap();
291 let resp = app.clone().oneshot(req).await.unwrap();
292 let status = resp.status().as_u16();
293 let body = resp.into_body().collect().await.unwrap().to_bytes();
294 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
295 (status, json)
296 }
297
298 #[tokio::test]
299 async fn api_status_returns_targets() {
300 let pool = db::connect_in_memory().await.unwrap();
301 let config = test_config();
302 let app = pom::api::router(pool.clone(), config, None);
303
304 // Insert a health check so there's data
305 let snapshot = HealthSnapshot {
306 id: None,
307 target: "mnw".to_string(),
308 status: HealthStatus::Operational,
309 checked_at: "2026-03-10T00:00:00Z".to_string(),
310 response_time_ms: 120,
311 details: None,
312 error: None,
313 };
314 db::insert_health_check(&pool, &snapshot).await.unwrap();
315
316 let (status, json) = api_get(&app, "/api/status").await;
317 assert_eq!(status, 200);
318 assert!(json["targets"]["mnw"].is_object());
319 assert_eq!(json["targets"]["mnw"]["label"], "MakeNotWork");
320 assert_eq!(json["targets"]["mnw"]["latest"]["status"], "operational");
321 assert_eq!(json["targets"]["mnw"]["latest"]["response_time_ms"], 120);
322 }
323
324 #[tokio::test]
325 async fn api_status_target_not_found() {
326 let pool = db::connect_in_memory().await.unwrap();
327 let config = test_config();
328 let app = pom::api::router(pool, config, None);
329
330 let (status, json) = api_get(&app, "/api/status/nonexistent").await;
331 assert_eq!(status, 404);
332 assert!(json["error"].as_str().unwrap().contains("unknown target"));
333 }
334
335 #[tokio::test]
336 async fn api_peer_info_returns_instance() {
337 let pool = db::connect_in_memory().await.unwrap();
338 let config = test_config();
339 let mesh = test_mesh();
340 let app = pom::api::router(pool, config, Some(mesh));
341
342 let (status, json) = api_get(&app, "/api/peer/info").await;
343 assert_eq!(status, 200);
344 assert_eq!(json["id"], "test-uuid");
345 assert_eq!(json["name"], "test-node");
346 }
347
348 #[tokio::test]
349 async fn api_peer_info_disabled_without_mesh() {
350 let pool = db::connect_in_memory().await.unwrap();
351 let config = test_config();
352 let app = pom::api::router(pool, config, None);
353
354 let (status, json) = api_get(&app, "/api/peer/info").await;
355 assert_eq!(status, 503);
356 assert!(json["error"].as_str().unwrap().contains("not enabled"));
357 }
358
359 #[tokio::test]
360 async fn api_mesh_view_includes_self() {
361 let pool = db::connect_in_memory().await.unwrap();
362 let config = test_config();
363 let mesh = test_mesh();
364 let app = pom::api::router(pool, config, Some(mesh));
365
366 let (status, json) = api_get(&app, "/api/mesh").await;
367 assert_eq!(status, 200);
368 assert!(json["instances"]["test-node"].is_object());
369 assert_eq!(json["instances"]["test-node"]["instance"]["id"], "test-uuid");
370 }
371
372 // --- Migration tests ---
373
374 #[tokio::test]
375 async fn migration_fresh_db_reaches_latest_version() {
376 // A fresh in-memory DB should run all migrations and reach the latest version.
377 let pool = db::connect_in_memory().await.unwrap();
378 let version = db::get_schema_version(&pool).await.unwrap();
379 assert_eq!(version, 9);
380
381 // Verify the schema_version table has entries for each migration
382 let rows = sqlx::query_as::<_, (i64, String)>(
383 "SELECT version, description FROM schema_version ORDER BY version",
384 )
385 .fetch_all(&pool)
386 .await
387 .unwrap();
388 assert_eq!(rows.len(), 9);
389 assert_eq!(rows[0].0, 1);
390 assert_eq!(rows[0].1, "initial schema");
391 assert_eq!(rows[1].0, 2);
392 assert_eq!(rows[1].1, "add alerts table");
393 assert_eq!(rows[2].0, 3);
394 assert_eq!(rows[2].1, "add tls_checks table");
395 assert_eq!(rows[3].0, 4);
396 assert_eq!(rows[3].1, "add incidents table");
397 assert_eq!(rows[4].0, 5);
398 assert_eq!(rows[4].1, "add route_checks table");
399 assert_eq!(rows[5].0, 6);
400 assert_eq!(rows[5].1, "add dns_checks and whois_checks tables");
401 assert_eq!(rows[6].0, 7);
402 assert_eq!(rows[6].1, "add test_details table");
403 assert_eq!(rows[7].0, 8);
404 assert_eq!(rows[7].1, "add cors_checks table");
405 assert_eq!(rows[8].0, 9);
406 assert_eq!(rows[8].1, "add backup_checks table");
407
408 // Verify actual tables were created by inserting data
409 let snapshot = HealthSnapshot {
410 id: None,
411 target: "test".to_string(),
412 status: HealthStatus::Operational,
413 checked_at: "2026-03-11T00:00:00Z".to_string(),
414 response_time_ms: 50,
415 details: None,
416 error: None,
417 };
418 let id = db::insert_health_check(&pool, &snapshot).await.unwrap();
419 assert!(id > 0);
420 }
421
422 #[tokio::test]
423 async fn migration_already_current_is_idempotent() {
424 // Running migrations on an already-migrated DB should be a no-op.
425 let pool = db::connect_in_memory().await.unwrap();
426 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 9);
427
428 // Run migrations again
429 db::run_migrations(&pool).await.unwrap();
430 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 9);
431
432 // schema_version should still have exactly nine entries (not duplicated)
433 let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version")
434 .fetch_one(&pool)
435 .await
436 .unwrap();
437 assert_eq!(count.0, 9);
438 }
439
440 #[tokio::test]
441 async fn migration_detects_pre_migration_database() {
442 // Simulate a pre-migration database: create tables manually without schema_version.
443 let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
444 let pool = sqlx::sqlite::SqlitePoolOptions::new()
445 .max_connections(1)
446 .connect_with(opts)
447 .await
448 .unwrap();
449
450 // Create the old-style tables directly (as init_schema used to do)
451 sqlx::query(
452 "CREATE TABLE health_checks (
453 id INTEGER PRIMARY KEY AUTOINCREMENT,
454 target TEXT NOT NULL,
455 status TEXT NOT NULL,
456 checked_at TEXT NOT NULL,
457 response_time_ms INTEGER NOT NULL,
458 details_json TEXT,
459 error TEXT
460 )",
461 )
462 .execute(&pool)
463 .await
464 .unwrap();
465
466 sqlx::query(
467 "CREATE TABLE test_runs (
468 id INTEGER PRIMARY KEY AUTOINCREMENT,
469 target TEXT NOT NULL,
470 started_at TEXT NOT NULL,
471 finished_at TEXT,
472 duration_secs INTEGER,
473 exit_code INTEGER,
474 passed INTEGER NOT NULL,
475 summary_json TEXT NOT NULL,
476 raw_output TEXT NOT NULL,
477 filter TEXT
478 )",
479 )
480 .execute(&pool)
481 .await
482 .unwrap();
483
484 // Insert some existing data to verify it's preserved
485 sqlx::query(
486 "INSERT INTO health_checks (target, status, checked_at, response_time_ms)
487 VALUES ('mnw', 'operational', '2026-03-10T00:00:00Z', 100)",
488 )
489 .execute(&pool)
490 .await
491 .unwrap();
492
493 // Now run migrations — should detect existing tables, stamp as v1, then run v2+v3+v4+v5+v6
494 db::run_migrations(&pool).await.unwrap();
495
496 // Version should be 9 (stamped v1 + ran v2..v9)
497 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 9);
498
499 // Description should indicate pre-existing
500 let row = sqlx::query_as::<_, (String,)>(
501 "SELECT description FROM schema_version WHERE version = 1",
502 )
503 .fetch_one(&pool)
504 .await
505 .unwrap();
506 assert!(row.0.contains("pre-existing"));
507
508 // Existing data should be preserved
509 let history = db::get_health_history(&pool, Some("mnw"), 10).await.unwrap();
510 assert_eq!(history.len(), 1);
511 assert_eq!(history[0].response_time_ms, 100);
512 }
513
514 // --- MCP tool tests ---
515
516 fn test_server(pool: sqlx::SqlitePool) -> PomServer {
517 PomServer::new(pool, test_config())
518 }
519
520 #[tokio::test]
521 async fn tool_get_status_with_data() {
522 let pool = db::connect_in_memory().await.unwrap();
523 let server = test_server(pool.clone());
524
525 // Insert health + test data
526 let snapshot = HealthSnapshot {
527 id: None,
528 target: "mnw".to_string(),
529 status: HealthStatus::Operational,
530 checked_at: "2026-03-10T00:00:00Z".to_string(),
531 response_time_ms: 95,
532 details: Some(HealthDetails {
533 version: Some("2.1.0".to_string()),
534 uptime: Some("3d".to_string()),
535 checks: None,
536 monitoring: None,
537 }),
538 error: None,
539 };
540 db::insert_health_check(&pool, &snapshot).await.unwrap();
541
542 let run = TestRun {
543 id: None,
544 target: "mnw".to_string(),
545 started_at: "2026-03-10T00:00:00Z".to_string(),
546 finished_at: Some("2026-03-10T00:01:00Z".to_string()),
547 duration_secs: Some(60),
548 exit_code: Some(0),
549 passed: true,
550 summary: TestSummary {
551 steps: vec![StepResult { name: "cargo test".to_string(), passed: true }],
552 total_passed: Some(100),
553 total_failed: Some(0),
554 details: vec![],
555 },
556 raw_output: "all good".to_string(),
557 filter: None,
558 };
559 db::insert_test_run(&pool, &run).await.unwrap();
560
561 let result = server.get_status_impl().await.unwrap();
562 assert!(result.contains("## mnw (MakeNotWork)"));
563 assert!(result.contains("operational"));
564 assert!(result.contains("95ms"));
565 assert!(result.contains("Version: 2.1.0"));
566 assert!(result.contains("Uptime: 3d"));
567 assert!(result.contains("PASSED"));
568 assert!(result.contains("100 passed, 0 failed"));
569 assert!(result.contains("PASS cargo test"));
570 }
571
572 #[tokio::test]
573 async fn tool_get_status_no_data() {
574 let pool = db::connect_in_memory().await.unwrap();
575 let server = test_server(pool);
576
577 let result = server.get_status_impl().await.unwrap();
578 assert!(result.contains("Health: no data"));
579 assert!(result.contains("Tests: no data"));
580 }
581
582 #[tokio::test]
583 async fn tool_get_status_no_targets() {
584 let pool = db::connect_in_memory().await.unwrap();
585 let config: pom::config::Config = toml::from_str("").unwrap();
586 let server = PomServer::new(pool, config);
587
588 let result = server.get_status_impl().await.unwrap();
589 assert_eq!(result, "No targets configured.");
590 }
591
592 #[tokio::test]
593 async fn tool_list_targets() {
594 let pool = db::connect_in_memory().await.unwrap();
595 let server = test_server(pool);
596
597 let result = server.list_targets_impl().await.unwrap();
598 let targets: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
599 assert_eq!(targets.len(), 1);
600 assert_eq!(targets[0]["name"], "mnw");
601 assert_eq!(targets[0]["label"], "MakeNotWork");
602 assert_eq!(targets[0]["has_health"], true);
603 assert_eq!(targets[0]["has_tests"], false); // test_config has no tests section
604 }
605
606 #[tokio::test]
607 async fn tool_health_history_with_data() {
608 let pool = db::connect_in_memory().await.unwrap();
609 let server = test_server(pool.clone());
610
611 for i in 0..3 {
612 let snapshot = HealthSnapshot {
613 id: None,
614 target: "mnw".to_string(),
615 status: HealthStatus::Operational,
616 checked_at: format!("2026-03-10T0{i}:00:00Z"),
617 response_time_ms: 100 + i * 10,
618 details: None,
619 error: None,
620 };
621 db::insert_health_check(&pool, &snapshot).await.unwrap();
622 }
623
624 let params = pom::tools::health::HealthHistoryParams {
625 target: Some("mnw".to_string()),
626 limit: Some(2),
627 };
628 let result = server.health_history_impl(params).await.unwrap();
629 let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
630 assert_eq!(history.len(), 2);
631 }
632
633 #[tokio::test]
634 async fn tool_health_history_empty() {
635 let pool = db::connect_in_memory().await.unwrap();
636 let server = test_server(pool);
637
638 let params = pom::tools::health::HealthHistoryParams {
639 target: None,
640 limit: None,
641 };
642 let result = server.health_history_impl(params).await.unwrap();
643 assert_eq!(result, "No health check history.");
644 }
645
646 #[tokio::test]
647 async fn tool_health_history_default_limit() {
648 let pool = db::connect_in_memory().await.unwrap();
649 let server = test_server(pool.clone());
650
651 for i in 0..15 {
652 let snapshot = HealthSnapshot {
653 id: None,
654 target: "mnw".to_string(),
655 status: HealthStatus::Operational,
656 checked_at: format!("2026-03-10T{:02}:00:00Z", i),
657 response_time_ms: 100,
658 details: None,
659 error: None,
660 };
661 db::insert_health_check(&pool, &snapshot).await.unwrap();
662 }
663
664 let params = pom::tools::health::HealthHistoryParams {
665 target: None,
666 limit: None, // should default to 10
667 };
668 let result = server.health_history_impl(params).await.unwrap();
669 let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
670 assert_eq!(history.len(), 10);
671 }
672
673 #[tokio::test]
674 async fn tool_check_health_unknown_target() {
675 let pool = db::connect_in_memory().await.unwrap();
676 let server = test_server(pool);
677
678 let params = pom::tools::health::CheckHealthParams {
679 target: Some("nonexistent".to_string()),
680 };
681 let result = server.check_health_impl(params).await.unwrap();
682 assert_eq!(result, "Unknown target: nonexistent");
683 }
684
685 #[tokio::test]
686 async fn tool_test_history_strips_raw_output() {
687 let pool = db::connect_in_memory().await.unwrap();
688 let server = test_server(pool.clone());
689
690 let run = TestRun {
691 id: None,
692 target: "mnw".to_string(),
693 started_at: "2026-03-10T00:00:00Z".to_string(),
694 finished_at: None,
695 duration_secs: None,
696 exit_code: None,
697 passed: true,
698 summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![] },
699 raw_output: "HUGE OUTPUT THAT SHOULD NOT APPEAR".to_string(),
700 filter: None,
701 };
702 db::insert_test_run(&pool, &run).await.unwrap();
703
704 let params = pom::tools::tests::TestHistoryParams {
705 target: Some("mnw".to_string()),
706 limit: None,
707 };
708 let result = server.test_history_impl(params).await.unwrap();
709 assert!(!result.contains("HUGE OUTPUT"));
710 assert!(result.contains("mnw"));
711 }
712
713 #[tokio::test]
714 async fn tool_test_history_empty() {
715 let pool = db::connect_in_memory().await.unwrap();
716 let server = test_server(pool);
717
718 let params = pom::tools::tests::TestHistoryParams {
719 target: None,
720 limit: None,
721 };
722 let result = server.test_history_impl(params).await.unwrap();
723 assert_eq!(result, "No test run history.");
724 }
725
726 #[tokio::test]
727 async fn tool_last_test_output_returns_raw() {
728 let pool = db::connect_in_memory().await.unwrap();
729 let server = test_server(pool.clone());
730
731 let run = TestRun {
732 id: None,
733 target: "mnw".to_string(),
734 started_at: "2026-03-10T00:00:00Z".to_string(),
735 finished_at: None,
736 duration_secs: None,
737 exit_code: None,
738 passed: true,
739 summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![] },
740 raw_output: "running 42 tests\ntest result: ok".to_string(),
741 filter: None,
742 };
743 db::insert_test_run(&pool, &run).await.unwrap();
744
745 let params = pom::tools::tests::LastTestOutputParams {
746 target: "mnw".to_string(),
747 };
748 let result = server.last_test_output_impl(params).await.unwrap();
749 assert_eq!(result, "running 42 tests\ntest result: ok");
750 }
751
752 #[tokio::test]
753 async fn tool_last_test_output_no_runs() {
754 let pool = db::connect_in_memory().await.unwrap();
755 let server = test_server(pool);
756
757 let params = pom::tools::tests::LastTestOutputParams {
758 target: "mnw".to_string(),
759 };
760 let result = server.last_test_output_impl(params).await.unwrap();
761 assert_eq!(result, "No test runs found for target 'mnw'");
762 }
763
764 #[tokio::test]
765 async fn tool_run_tests_unknown_target() {
766 let pool = db::connect_in_memory().await.unwrap();
767 let server = test_server(pool);
768
769 let params = pom::tools::tests::RunTestsParams {
770 target: "nonexistent".to_string(),
771 filter: None,
772 };
773 let result = server.run_tests_impl(params).await;
774 assert!(result.is_err());
775 assert!(result.unwrap_err().to_string().contains("Unknown target"));
776 }
777
778 #[tokio::test]
779 async fn tool_run_tests_no_test_config() {
780 let pool = db::connect_in_memory().await.unwrap();
781 let server = test_server(pool); // test_config has mnw with health but no tests
782
783 let params = pom::tools::tests::RunTestsParams {
784 target: "mnw".to_string(),
785 filter: None,
786 };
787 let result = server.run_tests_impl(params).await;
788 assert!(result.is_err());
789 assert!(result.unwrap_err().to_string().contains("no test configuration"));
790 }
791
792 // --- Alert tests ---
793
794 #[tokio::test]
795 async fn migration_v2_creates_alerts_table() {
796 let pool = db::connect_in_memory().await.unwrap();
797 let version = db::get_schema_version(&pool).await.unwrap();
798 assert_eq!(version, 9);
799
800 // Verify alerts table exists by inserting
801 let id = db::insert_alert(&pool, "mnw", "health", Some("operational"), Some("error"), None)
802 .await
803 .unwrap();
804 assert!(id > 0);
805 }
806
807 #[tokio::test]
808 async fn alert_insert_and_query() {
809 let pool = db::connect_in_memory().await.unwrap();
810
811 db::insert_alert(&pool, "health:mnw", "health", Some("operational"), Some("error"), Some("connection refused"))
812 .await
813 .unwrap();
814
815 let latest = db::get_latest_alert_for_target(&pool, "health:mnw").await.unwrap();
816 assert!(latest.is_some());
817 let row = latest.unwrap();
818 assert_eq!(row.target, "health:mnw");
819 assert_eq!(row.alert_type, "health");
820 assert_eq!(row.from_status.as_deref(), Some("operational"));
821 assert_eq!(row.to_status.as_deref(), Some("error"));
822 assert_eq!(row.error.as_deref(), Some("connection refused"));
823 }
824
825 #[tokio::test]
826 async fn alert_query_returns_none_for_unknown_target() {
827 let pool = db::connect_in_memory().await.unwrap();
828
829 let latest = db::get_latest_alert_for_target(&pool, "nonexistent").await.unwrap();
830 assert!(latest.is_none());
831 }
832
833 #[tokio::test]
834 async fn prune_removes_old_alerts() {
835 let pool = db::connect_in_memory().await.unwrap();
836
837 // Insert an old alert directly with old timestamp
838 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
839 sqlx::query(
840 "INSERT INTO alerts (target, alert_type, sent_at) VALUES (?, ?, ?)",
841 )
842 .bind("mnw")
843 .bind("health")
844 .bind(&old_time)
845 .execute(&pool)
846 .await
847 .unwrap();
848
849 // Insert a recent alert
850 db::insert_alert(&pool, "mnw", "health", None, None, None).await.unwrap();
851
852 let result = db::prune_old_records(&pool, 30).await.unwrap();
853 assert_eq!(result.alerts, 1);
854
855 // Recent alert should remain
856 let latest = db::get_latest_alert_for_target(&pool, "mnw").await.unwrap();
857 assert!(latest.is_some());
858 }
859
860 // --- TLS check tests ---
861
862 #[tokio::test]
863 async fn migration_v3_creates_tls_checks_table() {
864 let pool = db::connect_in_memory().await.unwrap();
865 let version = db::get_schema_version(&pool).await.unwrap();
866 assert_eq!(version, 9);
867
868 // Verify tls_checks table exists by inserting
869 let status = pom::types::TlsStatus {
870 target: "mnw".to_string(),
871 host: "makenot.work".to_string(),
872 port: 443,
873 valid: true,
874 days_remaining: 47,
875 not_before: "2026-01-10T00:00:00Z".to_string(),
876 not_after: "2026-04-27T00:00:00Z".to_string(),
877 subject: "CN=makenot.work".to_string(),
878 issuer: "CN=Let's Encrypt".to_string(),
879 checked_at: "2026-03-11T00:00:00Z".to_string(),
880 error: None,
881 };
882 let id = db::insert_tls_check(&pool, &status).await.unwrap();
883 assert!(id > 0);
884 }
885
886 #[tokio::test]
887 async fn tls_check_insert_and_query() {
888 let pool = db::connect_in_memory().await.unwrap();
889
890 let status = pom::types::TlsStatus {
891 target: "mnw".to_string(),
892 host: "makenot.work".to_string(),
893 port: 443,
894 valid: true,
895 days_remaining: 47,
896 not_before: "2026-01-10T00:00:00Z".to_string(),
897 not_after: "2026-04-27T00:00:00Z".to_string(),
898 subject: "CN=makenot.work".to_string(),
899 issuer: "CN=Let's Encrypt".to_string(),
900 checked_at: "2026-03-11T00:00:00Z".to_string(),
901 error: None,
902 };
903 db::insert_tls_check(&pool, &status).await.unwrap();
904
905 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap();
906 assert!(latest.is_some());
907 let row = latest.unwrap();
908 assert_eq!(row.host, "makenot.work");
909 assert!(row.valid);
910 assert_eq!(row.days_remaining, 47);
911 assert_eq!(row.subject, "CN=makenot.work");
912 assert!(row.error.is_none());
913 }
914
915 #[tokio::test]
916 async fn tls_check_error_stored() {
917 let pool = db::connect_in_memory().await.unwrap();
918
919 let status = pom::types::TlsStatus {
920 target: "mnw".to_string(),
921 host: "makenot.work".to_string(),
922 port: 443,
923 valid: false,
924 days_remaining: 0,
925 not_before: String::new(),
926 not_after: String::new(),
927 subject: String::new(),
928 issuer: String::new(),
929 checked_at: "2026-03-11T00:00:00Z".to_string(),
930 error: Some("connection refused".to_string()),
931 };
932 db::insert_tls_check(&pool, &status).await.unwrap();
933
934 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap().unwrap();
935 assert!(!latest.valid);
936 assert_eq!(latest.error.as_deref(), Some("connection refused"));
937 }
938
939 #[tokio::test]
940 async fn prune_removes_old_tls_checks() {
941 let pool = db::connect_in_memory().await.unwrap();
942
943 // Insert old TLS check
944 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
945 sqlx::query(
946 "INSERT INTO tls_checks (target, host, valid, days_remaining, not_before, not_after, subject, issuer, checked_at)
947 VALUES (?, ?, ?, ?, '', '', '', '', ?)",
948 )
949 .bind("mnw")
950 .bind("makenot.work")
951 .bind(true)
952 .bind(47)
953 .bind(&old_time)
954 .execute(&pool)
955 .await
956 .unwrap();
957
958 // Insert recent TLS check
959 let status = pom::types::TlsStatus {
960 target: "mnw".to_string(),
961 host: "makenot.work".to_string(),
962 port: 443,
963 valid: true,
964 days_remaining: 47,
965 not_before: String::new(),
966 not_after: String::new(),
967 subject: String::new(),
968 issuer: String::new(),
969 checked_at: chrono::Utc::now().to_rfc3339(),
970 error: None,
971 };
972 db::insert_tls_check(&pool, &status).await.unwrap();
973
974 let result = db::prune_old_records(&pool, 30).await.unwrap();
975 assert_eq!(result.tls, 1);
976
977 // Recent check should remain
978 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap();
979 assert!(latest.is_some());
980 }
981
982 #[tokio::test]
983 async fn api_status_target_includes_tls() {
984 let pool = db::connect_in_memory().await.unwrap();
985
986 // Config with TLS
987 let config: pom::config::Config = toml::from_str(
988 r#"
989 [targets.mnw]
990 label = "MakeNotWork"
991 [targets.mnw.health]
992 url = "https://makenot.work/health"
993 [targets.mnw.tls]
994 host = "makenot.work"
995 "#,
996 )
997 .unwrap();
998 let app = pom::api::router(pool.clone(), config, None);
999
1000 // Insert TLS check data
1001 let status = pom::types::TlsStatus {
1002 target: "mnw".to_string(),
1003 host: "makenot.work".to_string(),
1004 port: 443,
1005 valid: true,
1006 days_remaining: 47,
1007 not_before: "2026-01-10T00:00:00Z".to_string(),
1008 not_after: "2026-04-27T00:00:00Z".to_string(),
1009 subject: "CN=makenot.work".to_string(),
1010 issuer: "CN=Let's Encrypt".to_string(),
1011 checked_at: "2026-03-11T00:00:00Z".to_string(),
1012 error: None,
1013 };
1014 db::insert_tls_check(&pool, &status).await.unwrap();
1015
1016 let (http_status, json) = api_get(&app, "/api/status/mnw").await;
1017 assert_eq!(http_status, 200);
1018 assert!(json["tls"].is_object());
1019 assert_eq!(json["tls"]["host"], "makenot.work");
1020 assert_eq!(json["tls"]["days_remaining"], 47);
1021 assert_eq!(json["tls"]["valid"], true);
1022 }
1023
1024 #[tokio::test]
1025 async fn api_status_target_no_tls_omits_field() {
1026 let pool = db::connect_in_memory().await.unwrap();
1027 let config = test_config(); // no TLS config
1028 let app = pom::api::router(pool, config, None);
1029
1030 let (http_status, json) = api_get(&app, "/api/status/mnw").await;
1031 assert_eq!(http_status, 200);
1032 // tls field should be absent (skip_serializing_if)
1033 assert!(json.get("tls").is_none());
1034 }
1035
1036 #[tokio::test]
1037 async fn config_with_tls_parses() {
1038 let toml_str = r#"
1039 [targets.mnw]
1040 label = "MakeNotWork"
1041 [targets.mnw.tls]
1042 host = "makenot.work"
1043 port = 8443
1044 warn_days = 30
1045 "#;
1046 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
1047 let mnw = config.get_target("mnw").unwrap();
1048 let tls = mnw.tls.as_ref().unwrap();
1049 assert_eq!(tls.host, "makenot.work");
1050 assert_eq!(tls.port, 8443);
1051 assert_eq!(tls.warn_days, 30);
1052 }
1053
1054 #[tokio::test]
1055 async fn config_with_alerts_parses() {
1056 let toml = r#"
1057 [targets.mnw]
1058 label = "MakeNotWork"
1059 [targets.mnw.health]
1060 url = "https://makenot.work/health"
1061
1062 [alerts]
1063 postmark_token = "test-token-123"
1064 to = "pom-alerts@makenot.work"
1065 cooldown_secs = 120
1066 "#;
1067 let config: pom::config::Config = toml::from_str(toml).unwrap();
1068 let alerts = config.alerts.unwrap();
1069 assert_eq!(alerts.postmark_token.as_deref(), Some("test-token-123"));
1070 assert_eq!(alerts.to, "pom-alerts@makenot.work");
1071 assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
1072 assert_eq!(alerts.cooldown_secs, 120);
1073 }
1074
1075 // --- Incident tests ---
1076
1077 #[tokio::test]
1078 async fn migration_v4_creates_incidents_table() {
1079 let pool = db::connect_in_memory().await.unwrap();
1080 let version = db::get_schema_version(&pool).await.unwrap();
1081 assert_eq!(version, 9);
1082
1083 // Verify incidents table exists by inserting
1084 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
1085 .await
1086 .unwrap();
1087 assert!(id > 0);
1088 }
1089
1090 #[tokio::test]
1091 async fn incident_insert_and_close_lifecycle() {
1092 let pool = db::connect_in_memory().await.unwrap();
1093
1094 // Open an incident
1095 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
1096 .await
1097 .unwrap();
1098 assert!(id > 0);
1099
1100 // Should be visible as open
1101 let open = db::get_open_incident(&pool, "mnw").await.unwrap();
1102 assert!(open.is_some());
1103 let open = open.unwrap();
1104 assert_eq!(open.from_status, "operational");
1105 assert_eq!(open.to_status, "degraded");
1106 assert!(open.ended_at.is_none());
1107
1108 // Close it
1109 let closed_count = db::close_open_incidents(&pool, "mnw").await.unwrap();
1110 assert_eq!(closed_count, 1);
1111
1112 // No more open incidents
1113 let open = db::get_open_incident(&pool, "mnw").await.unwrap();
1114 assert!(open.is_none());
1115
1116 // Recent incidents should include the closed one
1117 let recent = db::get_recent_incidents(&pool, "mnw", 10).await.unwrap();
1118 assert_eq!(recent.len(), 1);
1119 assert!(recent[0].ended_at.is_some());
1120 assert!(recent[0].duration_secs.is_some());
1121 }
1122
1123 #[tokio::test]
1124 async fn incident_close_only_affects_target() {
1125 let pool = db::connect_in_memory().await.unwrap();
1126
1127 db::insert_incident(&pool, "mnw", "operational", "error").await.unwrap();
1128 db::insert_incident(&pool, "other", "operational", "error").await.unwrap();
1129
1130 // Close only mnw
1131 db::close_open_incidents(&pool, "mnw").await.unwrap();
1132
1133 assert!(db::get_open_incident(&pool, "mnw").await.unwrap().is_none());
1134 assert!(db::get_open_incident(&pool, "other").await.unwrap().is_some());
1135 }
1136
1137 #[tokio::test]
1138 async fn prune_removes_closed_incidents_only() {
1139 let pool = db::connect_in_memory().await.unwrap();
1140
1141 // Insert an old closed incident
1142 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
1143 sqlx::query(
1144 "INSERT INTO incidents (target, started_at, ended_at, duration_secs, from_status, to_status)
1145 VALUES (?, ?, ?, 3600, 'operational', 'error')",
1146 )
1147 .bind("mnw")
1148 .bind(&old_time)
1149 .bind(&old_time)
1150 .execute(&pool)
1151 .await
1152 .unwrap();
1153
1154 // Insert an old open incident (should NOT be pruned)
1155 sqlx::query(
1156 "INSERT INTO incidents (target, started_at, from_status, to_status)
1157 VALUES (?, ?, 'operational', 'error')",
1158 )
1159 .bind("mnw")
1160 .bind(&old_time)
1161 .execute(&pool)
1162 .await
1163 .unwrap();
1164
1165 let result = db::prune_old_records(&pool, 30).await.unwrap();
1166 assert_eq!(result.incidents, 1); // only the closed one
1167
1168 // The open incident should remain
1169 let remaining = db::get_recent_incidents(&pool, "mnw", 10).await.unwrap();
1170 assert_eq!(remaining.len(), 1);
1171 assert!(remaining[0].ended_at.is_none());
1172 }
1173
1174 #[tokio::test]
1175 async fn api_status_includes_incidents() {
1176 let pool = db::connect_in_memory().await.unwrap();
1177 let config = test_config();
1178 let app = pom::api::router(pool.clone(), config, None);
1179
1180 // Insert an open incident
1181 db::insert_incident(&pool, "mnw", "operational", "degraded").await.unwrap();
1182
1183 let (status, json) = api_get(&app, "/api/status/mnw").await;
1184 assert_eq!(status, 200);
1185 assert!(json["current_incident"].is_object());
1186 assert_eq!(json["current_incident"]["from_status"], "operational");
1187 assert_eq!(json["current_incident"]["to_status"], "degraded");
1188 assert!(!json["incidents"].as_array().unwrap().is_empty());
1189 }
1190
1191 #[tokio::test]
1192 async fn api_status_no_incidents_omits_fields() {
1193 let pool = db::connect_in_memory().await.unwrap();
1194 let config = test_config();
1195 let app = pom::api::router(pool, config, None);
1196
1197 let (status, json) = api_get(&app, "/api/status/mnw").await;
1198 assert_eq!(status, 200);
1199 // current_incident and incidents should be absent
1200 assert!(json.get("current_incident").is_none());
1201 assert!(json.get("incidents").is_none());
1202 }
1203
1204 // --- Route check tests ---
1205
1206 #[tokio::test]
1207 async fn migration_v5_creates_route_checks_table() {
1208 let pool = db::connect_in_memory().await.unwrap();
1209 let version = db::get_schema_version(&pool).await.unwrap();
1210 assert_eq!(version, 9);
1211
1212 // Verify route_checks table exists by inserting
1213 let result = pom::checks::routes::RouteCheckResult {
1214 target: "mnw".to_string(),
1215 path: "/".to_string(),
1216 status_code: 200,
1217 ok: true,
1218 checked_at: chrono::Utc::now().to_rfc3339(),
1219 response_time_ms: 50,
1220 error: None,
1221 };
1222 let id = db::insert_route_check(&pool, &result).await.unwrap();
1223 assert!(id > 0);
1224 }
1225
1226 #[tokio::test]
1227 async fn route_check_insert_and_latest_query() {
1228 let pool = db::connect_in_memory().await.unwrap();
1229
1230 // Insert two checks for different paths
1231 let r1 = pom::checks::routes::RouteCheckResult {
1232 target: "mnw".to_string(),
1233 path: "/".to_string(),
1234 status_code: 200,
1235 ok: true,
1236 checked_at: "2026-03-13T00:00:00Z".to_string(),
1237 response_time_ms: 50,
1238 error: None,
1239 };
1240 let r2 = pom::checks::routes::RouteCheckResult {
1241 target: "mnw".to_string(),
1242 path: "/docs".to_string(),
1243 status_code: 404,
1244 ok: false,
1245 checked_at: "2026-03-13T00:00:00Z".to_string(),
1246 response_time_ms: 30,
1247 error: Some("HTTP 404".to_string()),
1248 };
1249 db::insert_route_check(&pool, &r1).await.unwrap();
1250 db::insert_route_check(&pool, &r2).await.unwrap();
1251
1252 // Insert a newer check for "/" (should supersede the first)
1253 let r3 = pom::checks::routes::RouteCheckResult {
1254 target: "mnw".to_string(),
1255 path: "/".to_string(),
1256 status_code: 200,
1257 ok: true,
1258 checked_at: "2026-03-13T01:00:00Z".to_string(),
1259 response_time_ms: 45,
1260 error: None,
1261 };
1262 db::insert_route_check(&pool, &r3).await.unwrap();
1263
1264 let latest = db::get_latest_route_checks(&pool, "mnw").await.unwrap();
1265 assert_eq!(latest.len(), 2);
1266
1267 // "/" should have the newer check (45ms)
1268 let root = latest.iter().find(|r| r.path == "/").unwrap();
1269 assert_eq!(root.response_time_ms, 45);
1270 assert!(root.ok);
1271
1272 // "/docs" should still show 404
1273 let docs = latest.iter().find(|r| r.path == "/docs").unwrap();
1274 assert!(!docs.ok);
1275 assert_eq!(docs.status_code, 404);
1276 }
1277
1278 #[tokio::test]
1279 async fn route_check_prune() {
1280 let pool = db::connect_in_memory().await.unwrap();
1281
1282 let old = pom::checks::routes::RouteCheckResult {
1283 target: "mnw".to_string(),
1284 path: "/".to_string(),
1285 status_code: 200,
1286 ok: true,
1287 checked_at: "2020-01-01T00:00:00Z".to_string(),
1288 response_time_ms: 50,
1289 error: None,
1290 };
1291 db::insert_route_check(&pool, &old).await.unwrap();
1292
1293 let result = db::prune_old_records(&pool, 30).await.unwrap();
1294 assert_eq!(result.routes, 1);
1295 }
1296
1297 #[tokio::test]
1298 async fn api_status_includes_route_status() {
1299 let pool = db::connect_in_memory().await.unwrap();
1300 let config: pom::config::Config = toml::from_str(r#"
1301 [targets.mnw]
1302 label = "MakeNotWork"
1303 expected_routes = ["/"]
1304 [targets.mnw.health]
1305 url = "https://makenot.work/health"
1306 "#).unwrap();
1307 let app = pom::api::router(pool.clone(), config, None);
1308
1309 // Insert route checks
1310 let r1 = pom::checks::routes::RouteCheckResult {
1311 target: "mnw".to_string(),
1312 path: "/".to_string(),
1313 status_code: 200,
1314 ok: true,
1315 checked_at: chrono::Utc::now().to_rfc3339(),
1316 response_time_ms: 50,
1317 error: None,
1318 };
1319 db::insert_route_check(&pool, &r1).await.unwrap();
1320
1321 let (status, json) = api_get(&app, "/api/status/mnw").await;
1322 assert_eq!(status, 200);
1323 let routes = json["route_status"].as_array().unwrap();
1324 assert_eq!(routes.len(), 1);
1325 assert_eq!(routes[0]["path"], "/");
1326 assert_eq!(routes[0]["ok"], true);
1327 }
1328
1329 #[tokio::test]
1330 async fn api_status_omits_empty_route_status() {
1331 let pool = db::connect_in_memory().await.unwrap();
1332 let config = test_config();
1333 let app = pom::api::router(pool.clone(), config, None);
1334
1335 let (status, json) = api_get(&app, "/api/status/mnw").await;
1336 assert_eq!(status, 200);
1337 // route_status should be omitted when empty (skip_serializing_if)
1338 assert!(json.get("route_status").is_none());
1339 }
1340
1341 // --- Latency trending tests ---
1342
1343 #[tokio::test]
1344 async fn get_response_times_returns_ordered_data() {
1345 let pool = db::connect_in_memory().await.unwrap();
1346
1347 for i in 0..5 {
1348 let snapshot = HealthSnapshot {
1349 id: None,
1350 target: "mnw".to_string(),
1351 status: HealthStatus::Operational,
1352 checked_at: format!("2026-03-10T0{}:00:00+00:00", i),
1353 response_time_ms: 100 + i * 10,
1354 details: None,
1355 error: None,
1356 };
1357 db::insert_health_check(&pool, &snapshot).await.unwrap();
1358 }
1359
1360 let times = db::get_response_times(&pool, "mnw", "2026-03-10T00:00:00+00:00").await.unwrap();
1361 assert_eq!(times.len(), 5);
1362 // Verify ASC ordering
1363 assert!(times[0].1 <= times[4].1);
1364 }
1365
1366 #[tokio::test]
1367 async fn get_recent_response_times_filters_operational_only() {
1368 let pool = db::connect_in_memory().await.unwrap();
1369
1370 // Insert operational checks
1371 for i in 0..3 {
1372 let snapshot = HealthSnapshot {
1373 id: None,
1374 target: "mnw".to_string(),
1375 status: HealthStatus::Operational,
1376 checked_at: format!("2026-03-10T0{}:00:00Z", i),
1377 response_time_ms: 100 + i * 10,
1378 details: None,
1379 error: None,
1380 };
1381 db::insert_health_check(&pool, &snapshot).await.unwrap();
1382 }
1383
1384 // Insert non-operational checks
1385 let error_snapshot = HealthSnapshot {
1386 id: None,
1387 target: "mnw".to_string(),
1388 status: HealthStatus::Error,
1389 checked_at: "2026-03-10T03:00:00Z".to_string(),
1390 response_time_ms: 5000,
1391 details: None,
1392 error: Some("timeout".to_string()),
1393 };
1394 db::insert_health_check(&pool, &error_snapshot).await.unwrap();
1395
1396 let times = db::get_recent_response_times(&pool, "mnw", 10).await.unwrap();
1397 assert_eq!(times.len(), 3); // only operational
1398 // All should be our operational values, not the 5000ms error
1399 assert!(times.iter().all(|&t| t < 5000));
1400 }
1401
1402 #[tokio::test]
1403 async fn api_trends_returns_buckets() {
1404 let pool = db::connect_in_memory().await.unwrap();
1405 let config = test_config();
1406 let app = pom::api::router(pool.clone(), config, None);
1407
1408 // Insert hourly data points
1409 for i in 0..5 {
1410 let snapshot = HealthSnapshot {
1411 id: None,
1412 target: "mnw".to_string(),
1413 status: HealthStatus::Operational,
1414 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
1415 response_time_ms: 100 + i * 20,
1416 details: None,
1417 error: None,
1418 };
1419 db::insert_health_check(&pool, &snapshot).await.unwrap();
1420 }
1421
1422 let (status, json) = api_get(&app, "/api/trends/mnw?hours=24&bucket_minutes=60").await;
1423 assert_eq!(status, 200);
1424 assert_eq!(json["target"], "mnw");
1425 assert_eq!(json["window_hours"], 24);
1426 assert_eq!(json["bucket_minutes"], 60);
1427 assert!(!json["buckets"].as_array().unwrap().is_empty());
1428 assert!(json["overall"].is_object());
1429 }
1430
1431 #[tokio::test]
1432 async fn api_trends_nonexistent_target() {
1433 let pool = db::connect_in_memory().await.unwrap();
1434 let config = test_config();
1435 let app = pom::api::router(pool, config, None);
1436
1437 let (status, json) = api_get(&app, "/api/trends/nonexistent").await;
1438 assert_eq!(status, 404);
1439 assert!(json["error"].as_str().unwrap().contains("unknown target"));
1440 }
1441
1442 #[tokio::test]
1443 async fn api_status_includes_latency_24h_with_data() {
1444 let pool = db::connect_in_memory().await.unwrap();
1445 let config = test_config();
1446 let app = pom::api::router(pool.clone(), config, None);
1447
1448 // Insert recent operational check
1449 let snapshot = HealthSnapshot {
1450 id: None,
1451 target: "mnw".to_string(),
1452 status: HealthStatus::Operational,
1453 checked_at: chrono::Utc::now().to_rfc3339(),
1454 response_time_ms: 120,
1455 details: None,
1456 error: None,
1457 };
1458 db::insert_health_check(&pool, &snapshot).await.unwrap();
1459
1460 let (status, json) = api_get(&app, "/api/status/mnw").await;
1461 assert_eq!(status, 200);
1462 assert!(json["latency_24h"].is_object());
1463 assert_eq!(json["latency_24h"]["min_ms"], 120);
1464 assert_eq!(json["latency_24h"]["sample_count"], 1);
1465 }
1466
1467 #[tokio::test]
1468 async fn api_status_omits_latency_24h_when_no_data() {
1469 let pool = db::connect_in_memory().await.unwrap();
1470 let config = test_config();
1471 let app = pom::api::router(pool, config, None);
1472
1473 let (status, json) = api_get(&app, "/api/status/mnw").await;
1474 assert_eq!(status, 200);
1475 // latency_24h should be absent (skip_serializing_if)
1476 assert!(json.get("latency_24h").is_none());
1477 }
1478
1479 #[tokio::test]
1480 async fn config_trending_parses() {
1481 let toml = r#"
1482 [targets.mnw]
1483 label = "MakeNotWork"
1484 [targets.mnw.health]
1485 url = "https://makenot.work/health"
1486 [targets.mnw.health.trending]
1487 baseline_window_hours = 48
1488 spike_threshold = 1.5
1489 "#;
1490 let config: pom::config::Config = toml::from_str(toml).unwrap();
1491 let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap();
1492 assert_eq!(trending.baseline_window_hours, 48);
1493 assert_eq!(trending.spike_threshold, 1.5);
1494 }
1495
1496 #[tokio::test]
1497 async fn config_with_health_expect_parses() {
1498 let toml = r#"
1499 [targets.mnw]
1500 label = "MakeNotWork"
1501 [targets.mnw.health]
1502 url = "https://makenot.work/health"
1503 [targets.mnw.health.expect]
1504 status_code = 200
1505 json_fields = { "status" = "operational" }
1506 "#;
1507 let config: pom::config::Config = toml::from_str(toml).unwrap();
1508 let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap();
1509 assert_eq!(expect.status_code, Some(200));
1510 assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
1511 }
1512
1513 // --- Test staleness tests ---
1514
1515 fn test_config_with_tests() -> pom::config::Config {
1516 toml::from_str(
1517 r#"
1518 [targets.mnw]
1519 label = "MakeNotWork"
1520 [targets.mnw.health]
1521 url = "https://makenot.work/health"
1522 [targets.mnw.tests]
1523 ssh = "max@host"
1524 command = "./ci.sh"
1525 staleness_days = 7
1526 "#,
1527 )
1528 .unwrap()
1529 }
1530
1531 #[tokio::test]
1532 async fn get_version_at_time_returns_version() {
1533 let pool = db::connect_in_memory().await.unwrap();
1534
1535 // Insert a health check with version details
1536 let snapshot = HealthSnapshot {
1537 id: None,
1538 target: "mnw".to_string(),
1539 status: HealthStatus::Operational,
1540 checked_at: "2026-03-10T00:00:00Z".to_string(),
1541 response_time_ms: 95,
1542 details: Some(HealthDetails {
1543 version: Some("0.1.8".to_string()),
1544 uptime: None,
1545 checks: None,
1546 monitoring: None,
1547 }),
1548 error: None,
1549 };
1550 db::insert_health_check(&pool, &snapshot).await.unwrap();
1551
1552 let version = db::get_version_at_time(&pool, "mnw", "2026-03-10T01:00:00Z")
1553 .await
1554 .unwrap();
1555 assert_eq!(version, Some("0.1.8".to_string()));
1556 }
1557
1558 #[tokio::test]
1559 async fn get_version_at_time_returns_none_when_no_data() {
1560 let pool = db::connect_in_memory().await.unwrap();
1561
1562 let version = db::get_version_at_time(&pool, "mnw", "2026-03-10T01:00:00Z")
1563 .await
1564 .unwrap();
1565 assert!(version.is_none());
1566 }
1567
1568 #[tokio::test]
1569 async fn api_status_includes_staleness_version_change() {
1570 let pool = db::connect_in_memory().await.unwrap();
1571 let config = test_config_with_tests();
1572 let app = pom::api::router(pool.clone(), config, None);
1573
1574 // Insert health check with version 0.1.8 before test run
1575 let old_health = HealthSnapshot {
1576 id: None,
1577 target: "mnw".to_string(),
1578 status: HealthStatus::Operational,
1579 checked_at: "2026-03-09T00:00:00Z".to_string(),
1580 response_time_ms: 95,
1581 details: Some(HealthDetails {
1582 version: Some("0.1.8".to_string()),
1583 uptime: None,
1584 checks: None,
1585 monitoring: None,
1586 }),
1587 error: None,
1588 };
1589 db::insert_health_check(&pool, &old_health).await.unwrap();
1590
1591 // Insert test run at a time when version was 0.1.8
1592 let run = TestRun {
1593 id: None,
1594 target: "mnw".to_string(),
1595 started_at: chrono::Utc::now().to_rfc3339(),
1596 finished_at: None,
1597 duration_secs: Some(60),
1598 exit_code: Some(0),
1599 passed: true,
1600 summary: TestSummary { steps: vec![], total_passed: Some(100), total_failed: Some(0), details: vec![] },
1601 raw_output: String::new(),
1602 filter: None,
1603 };
1604 db::insert_test_run(&pool, &run).await.unwrap();
1605
1606 // Insert current health check with version 0.1.9
1607 let new_health = HealthSnapshot {
1608 id: None,
1609 target: "mnw".to_string(),
1610 status: HealthStatus::Operational,
1611 checked_at: chrono::Utc::now().to_rfc3339(),
1612 response_time_ms: 95,
1613 details: Some(HealthDetails {
1614 version: Some("0.1.9".to_string()),
1615 uptime: None,
1616 checks: None,
1617 monitoring: None,
1618 }),
1619 error: None,
1620 };
1621 db::insert_health_check(&pool, &new_health).await.unwrap();
1622
1623 let (status, json) = api_get(&app, "/api/status/mnw").await;
1624 assert_eq!(status, 200);
1625 assert!(json["test_staleness"].is_object());
1626 assert_eq!(json["test_staleness"]["stale"], true);
1627 let reason = json["test_staleness"]["reason"].as_str().unwrap();
1628 assert!(reason.contains("version changed"), "reason was: {reason}");
1629 }
1630
1631 #[tokio::test]
1632 async fn api_status_includes_staleness_by_age() {
1633 let pool = db::connect_in_memory().await.unwrap();
1634 let config = test_config_with_tests();
1635 let app = pom::api::router(pool.clone(), config, None);
1636
1637 // Insert old health check
1638 let old_time = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
1639 let health = HealthSnapshot {
1640 id: None,
1641 target: "mnw".to_string(),
1642 status: HealthStatus::Operational,
1643 checked_at: old_time.clone(),
1644 response_time_ms: 95,
1645 details: Some(HealthDetails {
1646 version: Some("0.1.9".to_string()),
1647 uptime: None,
1648 checks: None,
1649 monitoring: None,
1650 }),
1651 error: None,
1652 };
1653 db::insert_health_check(&pool, &health).await.unwrap();
1654
1655 // Insert old test run (10 days ago, same version)
1656 let run = TestRun {
1657 id: None,
1658 target: "mnw".to_string(),
1659 started_at: old_time,
1660 finished_at: None,
1661 duration_secs: Some(60),
1662 exit_code: Some(0),
1663 passed: true,
1664 summary: TestSummary { steps: vec![], total_passed: Some(100), total_failed: Some(0), details: vec![] },
1665 raw_output: String::new(),
1666 filter: None,
1667 };
1668 db::insert_test_run(&pool, &run).await.unwrap();
1669
1670 // Insert current health (same version)
1671 let current_health = HealthSnapshot {
1672 id: None,
1673 target: "mnw".to_string(),
1674 status: HealthStatus::Operational,
1675 checked_at: chrono::Utc::now().to_rfc3339(),
1676 response_time_ms: 95,
1677 details: Some(HealthDetails {
1678 version: Some("0.1.9".to_string()),
1679 uptime: None,
1680 checks: None,
1681 monitoring: None,
1682 }),
1683 error: None,
1684 };
1685 db::insert_health_check(&pool, &current_health).await.unwrap();
1686
1687 let (status, json) = api_get(&app, "/api/status/mnw").await;
1688 assert_eq!(status, 200);
1689 assert!(json["test_staleness"].is_object());
1690 assert_eq!(json["test_staleness"]["stale"], true);
1691 let reason = json["test_staleness"]["reason"].as_str().unwrap();
1692 assert!(reason.contains("days old"), "reason was: {reason}");
1693 }
1694
1695 #[tokio::test]
1696 async fn api_status_not_stale_when_fresh() {
1697 let pool = db::connect_in_memory().await.unwrap();
1698 let config = test_config_with_tests();
1699 let app = pom::api::router(pool.clone(), config, None);
1700
1701 // Insert health check with version
1702 let health = HealthSnapshot {
1703 id: None,
1704 target: "mnw".to_string(),
1705 status: HealthStatus::Operational,
1706 checked_at: chrono::Utc::now().to_rfc3339(),
1707 response_time_ms: 95,
1708 details: Some(HealthDetails {
1709 version: Some("0.1.9".to_string()),
1710 uptime: None,
1711 checks: None,
1712 monitoring: None,
1713 }),
1714 error: None,
1715 };
1716 db::insert_health_check(&pool, &health).await.unwrap();
1717
1718 // Insert recent test run
1719 let run = TestRun {
1720 id: None,
1721 target: "mnw".to_string(),
1722 started_at: chrono::Utc::now().to_rfc3339(),
1723 finished_at: None,
1724 duration_secs: Some(60),
1725 exit_code: Some(0),
1726 passed: true,
1727 summary: TestSummary { steps: vec![], total_passed: Some(100), total_failed: Some(0), details: vec![] },
1728 raw_output: String::new(),
1729 filter: None,
1730 };
1731 db::insert_test_run(&pool, &run).await.unwrap();
1732
1733 let (status, json) = api_get(&app, "/api/status/mnw").await;
1734 assert_eq!(status, 200);
1735 assert!(json["test_staleness"].is_object());
1736 assert_eq!(json["test_staleness"]["stale"], false);
1737 }
1738
1739 #[tokio::test]
1740 async fn config_staleness_days_parses() {
1741 let toml = r#"
1742 [targets.mnw]
1743 label = "MakeNotWork"
1744 [targets.mnw.tests]
1745 ssh = "host"
1746 command = "./ci.sh"
1747 staleness_days = 14
1748 "#;
1749 let config: pom::config::Config = toml::from_str(toml).unwrap();
1750 assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 14);
1751 }
1752
1753 #[tokio::test]
1754 async fn tool_get_status_shows_staleness() {
1755 let pool = db::connect_in_memory().await.unwrap();
1756 let config = test_config_with_tests();
1757 let server = PomServer::new(pool.clone(), config);
1758
1759 // No test data — should show stale
1760 let result = server.get_status_impl().await.unwrap();
1761 assert!(result.contains("STALE"), "output was: {result}");
1762 assert!(result.contains("no tests have been run"), "output was: {result}");
1763 }
1764
1765 #[tokio::test]
1766 async fn api_status_no_staleness_without_tests_config() {
1767 let pool = db::connect_in_memory().await.unwrap();
1768 let config = test_config(); // no tests section
1769 let app = pom::api::router(pool, config, None);
1770
1771 let (status, json) = api_get(&app, "/api/status/mnw").await;
1772 assert_eq!(status, 200);
1773 // test_staleness should be absent when no tests config
1774 assert!(json.get("test_staleness").is_none());
1775 }
1776
1777 // --- Prune days=0 guard tests ---
1778
1779 #[tokio::test]
1780 async fn prune_with_days_zero_is_noop() {
1781 let pool = db::connect_in_memory().await.unwrap();
1782
1783 // Insert a recent health check
1784 let snapshot = HealthSnapshot {
1785 id: None,
1786 target: "mnw".to_string(),
1787 status: HealthStatus::Operational,
1788 checked_at: chrono::Utc::now().to_rfc3339(),
1789 response_time_ms: 100,
1790 details: None,
1791 error: None,
1792 };
1793 db::insert_health_check(&pool, &snapshot).await.unwrap();
1794
1795 // Prune with days=0 should delete nothing
1796 let result = db::prune_old_records(&pool, 0).await.unwrap();
1797 assert_eq!(result.health, 0);
1798 assert_eq!(result.tests, 0);
1799 assert_eq!(result.heartbeats, 0);
1800 assert_eq!(result.alerts, 0);
1801 assert_eq!(result.tls, 0);
1802 assert_eq!(result.incidents, 0);
1803 assert_eq!(result.routes, 0);
1804 assert_eq!(result.dns, 0);
1805 assert_eq!(result.whois, 0);
1806
1807 // Records should still exist
1808 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
1809 assert_eq!(remaining.len(), 1);
1810 }
1811
1812 #[tokio::test]
1813 async fn prune_with_days_seven_keeps_recent() {
1814 let pool = db::connect_in_memory().await.unwrap();
1815
1816 // Insert a health check from yesterday
1817 let yesterday = HealthSnapshot {
1818 id: None,
1819 target: "mnw".to_string(),
1820 status: HealthStatus::Operational,
1821 checked_at: (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339(),
1822 response_time_ms: 100,
1823 details: None,
1824 error: None,
1825 };
1826 db::insert_health_check(&pool, &yesterday).await.unwrap();
1827
1828 // Insert a health check from 10 days ago
1829 let old = HealthSnapshot {
1830 id: None,
1831 target: "mnw".to_string(),
1832 status: HealthStatus::Operational,
1833 checked_at: (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(),
1834 response_time_ms: 200,
1835 details: None,
1836 error: None,
1837 };
1838 db::insert_health_check(&pool, &old).await.unwrap();
1839
1840 // Prune with days=7 should only delete the 10-day-old record
1841 let result = db::prune_old_records(&pool, 7).await.unwrap();
1842 assert_eq!(result.health, 1);
1843
1844 // Yesterday's record should remain
1845 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
1846 assert_eq!(remaining.len(), 1);
1847 assert_eq!(remaining[0].response_time_ms, 100);
1848 }
1849
1850 #[tokio::test]
1851 async fn prune_with_days_one_keeps_today() {
1852 let pool = db::connect_in_memory().await.unwrap();
1853
1854 // Insert a health check from now
1855 let today = HealthSnapshot {
1856 id: None,
1857 target: "mnw".to_string(),
1858 status: HealthStatus::Operational,
1859 checked_at: chrono::Utc::now().to_rfc3339(),
1860 response_time_ms: 100,
1861 details: None,
1862 error: None,
1863 };
1864 db::insert_health_check(&pool, &today).await.unwrap();
1865
1866 // Insert a health check from 2 days ago
1867 let old = HealthSnapshot {
1868 id: None,
1869 target: "mnw".to_string(),
1870 status: HealthStatus::Operational,
1871 checked_at: (chrono::Utc::now() - chrono::Duration::days(2)).to_rfc3339(),
1872 response_time_ms: 200,
1873 details: None,
1874 error: None,
1875 };
1876 db::insert_health_check(&pool, &old).await.unwrap();
1877
1878 // Prune with days=1 should delete the 2-day-old record, keep today's
1879 let result = db::prune_old_records(&pool, 1).await.unwrap();
1880 assert_eq!(result.health, 1);
1881
1882 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
1883 assert_eq!(remaining.len(), 1);
1884 assert_eq!(remaining[0].response_time_ms, 100);
1885 }
1886
1887 // --- SSH timeout_secs config test ---
1888
1889 #[test]
1890 fn ssh_config_timeout_secs_is_parsed() {
1891 let toml = r#"
1892 [targets.mnw]
1893 label = "MakeNotWork"
1894 [targets.mnw.tests]
1895 ssh = "hetzner"
1896 command = "./ci.sh"
1897 timeout_secs = 5
1898 "#;
1899 let config: pom::config::Config = toml::from_str(toml).unwrap();
1900 let tests = config.get_target("mnw").unwrap().tests.as_ref().unwrap();
1901 assert_eq!(tests.timeout_secs, 5);
1902 }
1903
1904 #[test]
1905 fn ssh_config_timeout_secs_default() {
1906 let toml = r#"
1907 [targets.mnw]
1908 label = "MakeNotWork"
1909 [targets.mnw.tests]
1910 ssh = "hetzner"
1911 command = "./ci.sh"
1912 "#;
1913 let config: pom::config::Config = toml::from_str(toml).unwrap();
1914 let tests = config.get_target("mnw").unwrap().tests.as_ref().unwrap();
1915 assert_eq!(tests.timeout_secs, 600); // default
1916 }
1917
1918 // --- Alert cooldown key consistency test ---
1919
1920 #[tokio::test]
1921 async fn alert_cooldown_key_matches_across_send_and_check() {
1922 let pool = db::connect_in_memory().await.unwrap();
1923
1924 let config = pom::config::AlertConfig {
1925 postmark_token: None,
1926 to: "test@example.com".to_string(),
1927 from: "PoM Alerts <pom@test.com>".to_string(),
1928 cooldown_secs: 300,
1929 wam_url: None,
1930 };
1931 let alerter = pom::alerts::Alerter::new(config, pool.clone(), "test".to_string());
1932
1933 // Send a health alert for target "example.com"
1934 alerter.send_health_alert("example.com", "Example", "operational", "error", None).await;
1935
1936 // The alert should be recorded with key "health:example.com"
1937 let alert = db::get_latest_alert_for_target(&pool, "health:example.com").await.unwrap();
1938 assert!(alert.is_some(), "alert should be recorded with prefixed key");
1939
1940 // The old (bare) key should have no record
1941 let bare = db::get_latest_alert_for_target(&pool, "example.com").await.unwrap();
1942 assert!(bare.is_none(), "no alert should exist under bare target name");
1943 }
1944
1945 // --- check_health integration tests (mock HTTP server) ---
1946
1947 #[tokio::test]
1948 async fn check_health_operational_json_response() {
1949 use axum::routing::get;
1950 use pom::checks::http::check_health;
1951 use pom::config::HealthConfig;
1952
1953 let app = axum::Router::new().route("/health", get(|| async {
1954 axum::Json(serde_json::json!({
1955 "status": "operational",
1956 "version": "1.0.0",
1957 "uptime": "2d 5h",
1958 }))
1959 }));
1960 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1961 let addr = listener.local_addr().unwrap();
1962 tokio::spawn(async move {
1963 axum::serve(listener, app).await.unwrap();
1964 });
1965
1966 let config = HealthConfig {
1967 url: format!("http://{addr}/health"),
1968 timeout_secs: 5,
1969 interval_secs: None,
1970 expect: None,
1971 trending: None,
1972 };
1973 let snapshot = check_health("test", &config, None).await;
1974 assert_eq!(snapshot.status, HealthStatus::Operational);
1975 assert!(snapshot.response_time_ms >= 0);
1976 let details = snapshot.details.unwrap();
1977 assert_eq!(details.version.as_deref(), Some("1.0.0"));
1978 assert_eq!(details.uptime.as_deref(), Some("2d 5h"));
1979 assert!(snapshot.error.is_none());
1980 }
1981
1982 #[tokio::test]
1983 async fn check_health_degraded_unknown_status() {
1984 use axum::routing::get;
1985 use pom::checks::http::check_health;
1986 use pom::config::HealthConfig;
1987
1988 let app = axum::Router::new().route("/health", get(|| async {
1989 axum::Json(serde_json::json!({
1990 "status": "starting_up",
1991 "version": "1.0.0",
1992 }))
1993 }));
1994 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1995 let addr = listener.local_addr().unwrap();
1996 tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
1997
1998 let config = HealthConfig {
1999 url: format!("http://{addr}/health"),
2000 timeout_secs: 5,
2001 interval_secs: None,
2002 expect: None,
2003 trending: None,
2004 };
2005 let snapshot = check_health("test", &config, None).await;
2006 assert_eq!(snapshot.status, HealthStatus::Degraded);
2007 }
2008
2009 #[tokio::test]
2010 async fn check_health_unreachable_target() {
2011 use pom::checks::http::check_health;
2012 use pom::config::HealthConfig;
2013
2014 let config = HealthConfig {
2015 url: "http://127.0.0.1:19999/health".to_string(),
2016 timeout_secs: 1,
2017 interval_secs: None,
2018 expect: None,
2019 trending: None,
2020 };
2021 let snapshot = check_health("test", &config, None).await;
2022 assert_eq!(snapshot.status, HealthStatus::Unreachable);
2023 assert!(snapshot.error.is_some());
2024 }
2025
2026 #[tokio::test]
2027 async fn check_health_with_expectations_passing() {
2028 use axum::routing::get;
2029 use pom::checks::http::check_health;
2030 use pom::config::{HealthConfig, HealthExpectation};
2031
2032 let app = axum::Router::new().route("/health", get(|| async {
2033 axum::Json(serde_json::json!({
2034 "status": "operational",
2035 "version": "1.0.0",
2036 }))
2037 }));
2038 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2039 let addr = listener.local_addr().unwrap();
2040 tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
2041
2042 let expect = HealthExpectation {
2043 status_code: Some(200),
2044 json_fields: [("status".to_string(), "operational".to_string())].into(),
2045 body_contains: None,
2046 };
2047 let config = HealthConfig {
2048 url: format!("http://{addr}/health"),
2049 timeout_secs: 5,
2050 interval_secs: None,
2051 expect: Some(expect.clone()),
2052 trending: None,
2053 };
2054 let snapshot = check_health("test", &config, Some(&expect)).await;
2055 assert_eq!(snapshot.status, HealthStatus::Operational);
2056 assert!(snapshot.error.is_none());
2057 }
2058
2059 #[tokio::test]
2060 async fn check_health_with_expectations_failing() {
2061 use axum::routing::get;
2062 use pom::checks::http::check_health;
2063 use pom::config::{HealthConfig, HealthExpectation};
2064
2065 let app = axum::Router::new().route("/health", get(|| async {
2066 axum::Json(serde_json::json!({
2067 "status": "degraded",
2068 "version": "1.0.0",
2069 }))
2070 }));
2071 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2072 let addr = listener.local_addr().unwrap();
2073 tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
2074
2075 let expect = HealthExpectation {
2076 status_code: Some(200),
2077 json_fields: [("status".to_string(), "operational".to_string())].into(),
2078 body_contains: None,
2079 };
2080 let config = HealthConfig {
2081 url: format!("http://{addr}/health"),
2082 timeout_secs: 5,
2083 interval_secs: None,
2084 expect: Some(expect.clone()),
2085 trending: None,
2086 };
2087 let snapshot = check_health("test", &config, Some(&expect)).await;
2088 assert_eq!(snapshot.status, HealthStatus::Degraded);
2089 assert!(snapshot.error.is_some());
2090 assert!(snapshot.error.unwrap().contains("expected \"operational\""));
2091 }
2092
2093 // --- check_tls integration test (self-signed cert) ---
2094
2095 #[tokio::test]
2096 async fn check_tls_with_test_cert() {
2097 use pom::checks::tls::check_tls;
2098 use pom::config::TlsConfig;
2099 use rcgen::generate_simple_self_signed;
2100 use tokio_rustls::rustls;
2101
2102 // Install crypto provider (tests don't go through main())
2103 let _ = rustls::crypto::ring::default_provider().install_default();
2104
2105 // Generate a self-signed cert
2106 let subject_alt_names = vec!["localhost".to_string()];
2107 let cert = generate_simple_self_signed(subject_alt_names).unwrap();
2108 let cert_der = cert.cert.der().clone();
2109 let key_der = cert.signing_key.serialize_der();
2110
2111 // Start a TLS server
2112 let server_config = rustls::ServerConfig::builder()
2113 .with_no_client_auth()
2114 .with_single_cert(
2115 vec![rustls_pki_types::CertificateDer::from(cert_der.to_vec())],
2116 rustls_pki_types::PrivateKeyDer::try_from(key_der).unwrap(),
2117 )
2118 .unwrap();
2119
2120 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(server_config));
2121 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2122 let port = listener.local_addr().unwrap().port();
2123
2124 tokio::spawn(async move {
2125 // Accept one connection to prove TLS works
2126 if let Ok((stream, _)) = listener.accept().await {
2127 let _ = acceptor.accept(stream).await;
2128 }
2129 });
2130
2131 let tls_config = TlsConfig {
2132 host: "localhost".to_string(),
2133 port,
2134 warn_days: 14,
2135 };
2136 let result = check_tls("test", &tls_config).await;
2137
2138 // Self-signed cert won't pass webpki validation, so this should return an error
2139 // but it should still complete without panic
2140 assert_eq!(result.target, "test");
2141 assert!(!result.checked_at.is_empty());
2142 // Self-signed cert → error expected (webpki root store doesn't include it)
2143 assert!(result.error.is_some() || result.valid);
2144 }
2145
2146 // --- API health endpoint test ---
2147
2148 #[tokio::test]
2149 async fn api_health_endpoint_returns_operational() {
2150 let pool = db::connect_in_memory().await.unwrap();
2151 let config = test_config();
2152 let app = pom::api::router(pool, config, None);
2153
2154 let (status, json) = api_get(&app, "/api/health").await;
2155 assert_eq!(status, 200);
2156 assert_eq!(json["status"], "operational");
2157 assert!(json["version"].as_str().is_some());
2158 }
2159
2160 #[tokio::test]
2161 async fn api_health_endpoint_no_auth_required() {
2162 let pool = db::connect_in_memory().await.unwrap();
2163 // Config with auth token — health endpoint should still be accessible
2164 let mut config = test_config();
2165 config.serve.api_token = Some("secret123".to_string());
2166 let app = pom::api::router(pool, config, None);
2167
2168 // No auth header, but /api/health should still work
2169 let (status, json) = api_get(&app, "/api/health").await;
2170 assert_eq!(status, 200);
2171 assert_eq!(json["status"], "operational");
2172 }
2173
2174 // --- Rate limit test ---
2175
2176 #[tokio::test]
2177 async fn api_rate_limit_rejects_excess_requests() {
2178 use pom::api::RateLimiter;
2179
2180 let limiter = RateLimiter::new(3, std::time::Duration::from_secs(60));
2181
2182 assert!(limiter.try_acquire()); // 1
2183 assert!(limiter.try_acquire()); // 2
2184 assert!(limiter.try_acquire()); // 3
2185 assert!(!limiter.try_acquire()); // 4 — should be rejected
2186 }
2187
2188 // --- Peer UUID mismatch state reset test ---
2189
2190 #[tokio::test]
2191 async fn peer_uuid_mismatch_updates_db_identity() {
2192 let pool = db::connect_in_memory().await.unwrap();
2193
2194 // Store initial identity
2195 db::store_peer_identity(&pool, "peer1", "old-uuid").await.unwrap();
2196 let stored = db::get_peer_identity(&pool, "peer1").await.unwrap();
2197 assert_eq!(stored, Some("old-uuid".to_string()));
2198
2199 // Update identity (simulating what happens on UUID mismatch)
2200 db::update_peer_identity(&pool, "peer1", "new-uuid").await.unwrap();
2201 let stored = db::get_peer_identity(&pool, "peer1").await.unwrap();
2202 assert_eq!(stored, Some("new-uuid".to_string()));
2203 }
2204
2205 // --- DNS check tests ---
2206
2207 #[tokio::test]
2208 async fn migration_v6_creates_dns_and_whois_tables() {
2209 let pool = db::connect_in_memory().await.unwrap();
2210 let version = db::get_schema_version(&pool).await.unwrap();
2211 assert_eq!(version, 9);
2212
2213 // Verify dns_checks table exists
2214 let dns_result = DnsCheckResult {
2215 target: "mnw".to_string(),
2216 name: "makenot.work".to_string(),
2217 record_type: pom::types::DnsRecordType::A,
2218 expected: vec!["5.78.144.244".to_string()],
2219 actual: vec!["5.78.144.244".to_string()],
2220 matches: true,
2221 checked_at: chrono::Utc::now().to_rfc3339(),
2222 error: None,
2223 };
2224 let id = db::insert_dns_check(&pool, &dns_result).await.unwrap();
2225 assert!(id > 0);
2226
2227 // Verify whois_checks table exists
2228 let whois_result = WhoisResult {
2229 target: "mnw".to_string(),
2230 domain: "makenot.work".to_string(),
2231 registrar: Some("Namecheap, Inc.".to_string()),
2232 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2233 days_remaining: Some(261),
2234 nameservers: vec!["ns1.example.com".to_string()],
2235 checked_at: chrono::Utc::now().to_rfc3339(),
2236 error: None,
2237 };
2238 let id = db::insert_whois_check(&pool, &whois_result).await.unwrap();
2239 assert!(id > 0);
2240 }
2241
2242 #[tokio::test]
2243 async fn dns_check_insert_and_query() {
2244 let pool = db::connect_in_memory().await.unwrap();
2245
2246 let result = DnsCheckResult {
2247 target: "mnw".to_string(),
2248 name: "makenot.work".to_string(),
2249 record_type: pom::types::DnsRecordType::A,
2250 expected: vec!["5.78.144.244".to_string()],
2251 actual: vec!["5.78.144.244".to_string()],
2252 matches: true,
2253 checked_at: "2026-03-15T00:00:00Z".to_string(),
2254 error: None,
2255 };
2256 db::insert_dns_check(&pool, &result).await.unwrap();
2257
2258 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2259 assert_eq!(latest.len(), 1);
2260 assert_eq!(latest[0].name, "makenot.work");
2261 assert_eq!(latest[0].record_type, "A");
2262 assert!(latest[0].matches);
2263 }
2264
2265 #[tokio::test]
2266 async fn dns_check_latest_per_name_and_type() {
2267 let pool = db::connect_in_memory().await.unwrap();
2268
2269 // Insert two checks for same name/type, different times
2270 let r1 = DnsCheckResult {
2271 target: "mnw".to_string(),
2272 name: "makenot.work".to_string(),
2273 record_type: pom::types::DnsRecordType::A,
2274 expected: vec!["1.2.3.4".to_string()],
2275 actual: vec!["5.6.7.8".to_string()],
2276 matches: false,
2277 checked_at: "2026-03-15T00:00:00Z".to_string(),
2278 error: None,
2279 };
2280 let r2 = DnsCheckResult {
2281 target: "mnw".to_string(),
2282 name: "makenot.work".to_string(),
2283 record_type: pom::types::DnsRecordType::A,
2284 expected: vec!["5.78.144.244".to_string()],
2285 actual: vec!["5.78.144.244".to_string()],
2286 matches: true,
2287 checked_at: "2026-03-15T01:00:00Z".to_string(),
2288 error: None,
2289 };
2290 db::insert_dns_check(&pool, &r1).await.unwrap();
2291 db::insert_dns_check(&pool, &r2).await.unwrap();
2292
2293 // Should return only the latest check per name+type
2294 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2295 assert_eq!(latest.len(), 1);
2296 assert!(latest[0].matches);
2297 }
2298
2299 #[tokio::test]
2300 async fn dns_check_multiple_records() {
2301 let pool = db::connect_in_memory().await.unwrap();
2302
2303 let r1 = DnsCheckResult {
2304 target: "mnw".to_string(),
2305 name: "makenot.work".to_string(),
2306 record_type: pom::types::DnsRecordType::A,
2307 expected: vec!["5.78.144.244".to_string()],
2308 actual: vec!["5.78.144.244".to_string()],
2309 matches: true,
2310 checked_at: "2026-03-15T00:00:00Z".to_string(),
2311 error: None,
2312 };
2313 let r2 = DnsCheckResult {
2314 target: "mnw".to_string(),
2315 name: "forums.makenot.work".to_string(),
2316 record_type: pom::types::DnsRecordType::A,
2317 expected: vec!["5.78.144.244".to_string()],
2318 actual: vec!["5.78.144.244".to_string()],
2319 matches: true,
2320 checked_at: "2026-03-15T00:00:00Z".to_string(),
2321 error: None,
2322 };
2323 db::insert_dns_check(&pool, &r1).await.unwrap();
2324 db::insert_dns_check(&pool, &r2).await.unwrap();
2325
2326 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2327 assert_eq!(latest.len(), 2);
2328 }
2329
2330 #[tokio::test]
2331 async fn dns_check_filters_by_target() {
2332 let pool = db::connect_in_memory().await.unwrap();
2333
2334 let r1 = DnsCheckResult {
2335 target: "mnw".to_string(),
2336 name: "makenot.work".to_string(),
2337 record_type: pom::types::DnsRecordType::A,
2338 expected: vec!["5.78.144.244".to_string()],
2339 actual: vec!["5.78.144.244".to_string()],
2340 matches: true,
2341 checked_at: chrono::Utc::now().to_rfc3339(),
2342 error: None,
2343 };
2344 let r2 = DnsCheckResult {
2345 target: "htpy".to_string(),
2346 name: "htpy.app".to_string(),
2347 record_type: pom::types::DnsRecordType::A,
2348 expected: vec!["5.78.135.189".to_string()],
2349 actual: vec!["5.78.135.189".to_string()],
2350 matches: true,
2351 checked_at: chrono::Utc::now().to_rfc3339(),
2352 error: None,
2353 };
2354 db::insert_dns_check(&pool, &r1).await.unwrap();
2355 db::insert_dns_check(&pool, &r2).await.unwrap();
2356
2357 let mnw_checks = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2358 assert_eq!(mnw_checks.len(), 1);
2359 assert_eq!(mnw_checks[0].name, "makenot.work");
2360 }
2361
2362 // --- WHOIS check tests ---
2363
2364 #[tokio::test]
2365 async fn whois_check_insert_and_query() {
2366 let pool = db::connect_in_memory().await.unwrap();
2367
2368 let result = WhoisResult {
2369 target: "mnw".to_string(),
2370 domain: "makenot.work".to_string(),
2371 registrar: Some("Namecheap, Inc.".to_string()),
2372 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2373 days_remaining: Some(261),
2374 nameservers: vec!["dns1.registrar-servers.com".to_string()],
2375 checked_at: "2026-03-15T00:00:00Z".to_string(),
2376 error: None,
2377 };
2378 db::insert_whois_check(&pool, &result).await.unwrap();
2379
2380 let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap();
2381 assert!(latest.is_some());
2382 let row = latest.unwrap();
2383 assert_eq!(row.domain, "makenot.work");
2384 assert_eq!(row.registrar.as_deref(), Some("Namecheap, Inc."));
2385 assert_eq!(row.days_remaining, Some(261));
2386 }
2387
2388 #[tokio::test]
2389 async fn whois_check_returns_latest() {
2390 let pool = db::connect_in_memory().await.unwrap();
2391
2392 let r1 = WhoisResult {
2393 target: "mnw".to_string(),
2394 domain: "makenot.work".to_string(),
2395 registrar: Some("Old Registrar".to_string()),
2396 expiry_date: Some("2026-06-01T00:00:00Z".to_string()),
2397 days_remaining: Some(78),
2398 nameservers: vec![],
2399 checked_at: "2026-03-15T00:00:00Z".to_string(),
2400 error: None,
2401 };
2402 let r2 = WhoisResult {
2403 target: "mnw".to_string(),
2404 domain: "makenot.work".to_string(),
2405 registrar: Some("New Registrar".to_string()),
2406 expiry_date: Some("2027-06-01T00:00:00Z".to_string()),
2407 days_remaining: Some(443),
2408 nameservers: vec![],
2409 checked_at: "2026-03-15T01:00:00Z".to_string(),
2410 error: None,
2411 };
2412 db::insert_whois_check(&pool, &r1).await.unwrap();
2413 db::insert_whois_check(&pool, &r2).await.unwrap();
2414
2415 let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap().unwrap();
2416 assert_eq!(latest.registrar.as_deref(), Some("New Registrar"));
2417 assert_eq!(latest.days_remaining, Some(443));
2418 }
2419
2420 #[tokio::test]
2421 async fn whois_check_returns_none_for_unknown_target() {
2422 let pool = db::connect_in_memory().await.unwrap();
2423
2424 let latest = db::get_latest_whois_check(&pool, "nonexistent").await.unwrap();
2425 assert!(latest.is_none());
2426 }
2427
2428 #[tokio::test]
2429 async fn whois_check_error_stored() {
2430 let pool = db::connect_in_memory().await.unwrap();
2431
2432 let result = WhoisResult {
2433 target: "mnw".to_string(),
2434 domain: "makenot.work".to_string(),
2435 registrar: None,
2436 expiry_date: None,
2437 days_remaining: None,
2438 nameservers: vec![],
2439 checked_at: chrono::Utc::now().to_rfc3339(),
2440 error: Some("WHOIS connection timed out".to_string()),
2441 };
2442 db::insert_whois_check(&pool, &result).await.unwrap();
2443
2444 let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap().unwrap();
2445 assert_eq!(latest.error.as_deref(), Some("WHOIS connection timed out"));
2446 assert!(latest.registrar.is_none());
2447 }
2448
2449 #[tokio::test]
2450 async fn cors_check_insert_and_query() {
2451 let pool = db::connect_in_memory().await.unwrap();
2452
2453 let result = CorsCheckResult {
2454 target: "mnw".to_string(),
2455 url: "https://storage.example.com/bucket/probe".to_string(),
2456 origin: "https://makenot.work".to_string(),
2457 method: "PUT".to_string(),
2458 passes: true,
2459 checked_at: chrono::Utc::now().to_rfc3339(),
2460 error: None,
2461 };
2462 db::insert_cors_check(&pool, &result).await.unwrap();
2463
2464 let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2465 assert_eq!(latest.len(), 1);
2466 assert!(latest[0].passes);
2467 assert_eq!(latest[0].url, "https://storage.example.com/bucket/probe");
2468 }
2469
2470 #[tokio::test]
2471 async fn cors_check_latest_per_url() {
2472 let pool = db::connect_in_memory().await.unwrap();
2473
2474 // Insert an old failing check
2475 let r1 = CorsCheckResult {
2476 target: "mnw".to_string(),
2477 url: "https://storage.example.com/bucket/probe".to_string(),
2478 origin: "https://makenot.work".to_string(),
2479 method: "PUT".to_string(),
2480 passes: false,
2481 checked_at: "2026-03-01T00:00:00Z".to_string(),
2482 error: Some("Missing Access-Control-Allow-Origin".to_string()),
2483 };
2484 db::insert_cors_check(&pool, &r1).await.unwrap();
2485
2486 // Insert a newer passing check for same URL
2487 let r2 = CorsCheckResult {
2488 target: "mnw".to_string(),
2489 url: "https://storage.example.com/bucket/probe".to_string(),
2490 origin: "https://makenot.work".to_string(),
2491 method: "PUT".to_string(),
2492 passes: true,
2493 checked_at: "2026-03-15T00:00:00Z".to_string(),
2494 error: None,
2495 };
2496 db::insert_cors_check(&pool, &r2).await.unwrap();
2497
2498 let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2499 // Should return only the latest (passing) check per URL
2500 assert_eq!(latest.len(), 1);
2501 assert!(latest[0].passes);
2502 }
2503
2504 #[tokio::test]
2505 async fn cors_check_filters_by_target() {
2506 let pool = db::connect_in_memory().await.unwrap();
2507
2508 let r1 = CorsCheckResult {
2509 target: "mnw".to_string(),
2510 url: "https://storage.example.com/bucket/probe".to_string(),
2511 origin: "https://makenot.work".to_string(),
2512 method: "PUT".to_string(),
2513 passes: true,
2514 checked_at: chrono::Utc::now().to_rfc3339(),
2515 error: None,
2516 };
2517 let r2 = CorsCheckResult {
2518 target: "other".to_string(),
2519 url: "https://other.example.com/probe".to_string(),
2520 origin: "https://other.app".to_string(),
2521 method: "PUT".to_string(),
2522 passes: false,
2523 checked_at: chrono::Utc::now().to_rfc3339(),
2524 error: Some("Failed".to_string()),
2525 };
2526 db::insert_cors_check(&pool, &r1).await.unwrap();
2527 db::insert_cors_check(&pool, &r2).await.unwrap();
2528
2529 let mnw_checks = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2530 assert_eq!(mnw_checks.len(), 1);
2531 assert_eq!(mnw_checks[0].target, "mnw");
2532 }
2533
2534 #[tokio::test]
2535 async fn prune_removes_old_dns_checks() {
2536 let pool = db::connect_in_memory().await.unwrap();
2537
2538 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2539 sqlx::query(
2540 "INSERT INTO dns_checks (target, name, record_type, expected, actual, matches, checked_at)
2541 VALUES (?, ?, ?, '[]', '[]', 1, ?)",
2542 )
2543 .bind("mnw")
2544 .bind("makenot.work")
2545 .bind("A")
2546 .bind(&old_time)
2547 .execute(&pool)
2548 .await
2549 .unwrap();
2550
2551 // Insert recent DNS check
2552 let recent = DnsCheckResult {
2553 target: "mnw".to_string(),
2554 name: "makenot.work".to_string(),
2555 record_type: pom::types::DnsRecordType::A,
2556 expected: vec![],
2557 actual: vec![],
2558 matches: true,
2559 checked_at: chrono::Utc::now().to_rfc3339(),
2560 error: None,
2561 };
2562 db::insert_dns_check(&pool, &recent).await.unwrap();
2563
2564 let result = db::prune_old_records(&pool, 30).await.unwrap();
2565 assert_eq!(result.dns, 1);
2566
2567 let remaining = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2568 assert_eq!(remaining.len(), 1);
2569 }
2570
2571 #[tokio::test]
2572 async fn prune_removes_old_whois_checks() {
2573 let pool = db::connect_in_memory().await.unwrap();
2574
2575 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2576 sqlx::query(
2577 "INSERT INTO whois_checks (target, domain, checked_at) VALUES (?, ?, ?)",
2578 )
2579 .bind("mnw")
2580 .bind("makenot.work")
2581 .bind(&old_time)
2582 .execute(&pool)
2583 .await
2584 .unwrap();
2585
2586 // Insert recent WHOIS check
2587 let recent = WhoisResult {
2588 target: "mnw".to_string(),
2589 domain: "makenot.work".to_string(),
2590 registrar: None,
2591 expiry_date: None,
2592 days_remaining: None,
2593 nameservers: vec![],
2594 checked_at: chrono::Utc::now().to_rfc3339(),
2595 error: None,
2596 };
2597 db::insert_whois_check(&pool, &recent).await.unwrap();
2598
2599 let result = db::prune_old_records(&pool, 30).await.unwrap();
2600 assert_eq!(result.whois, 1);
2601
2602 let remaining = db::get_latest_whois_check(&pool, "mnw").await.unwrap();
2603 assert!(remaining.is_some());
2604 }
2605
2606 // --- DNS/WHOIS API tests ---
2607
2608 #[tokio::test]
2609 async fn api_status_includes_dns_status() {
2610 let pool = db::connect_in_memory().await.unwrap();
2611 let config: pom::config::Config = toml::from_str(r#"
2612 [targets.mnw]
2613 label = "MakeNotWork"
2614 [targets.mnw.health]
2615 url = "https://makenot.work/health"
2616 [[targets.mnw.dns]]
2617 name = "makenot.work"
2618 record_type = "A"
2619 expected = ["5.78.144.244"]
2620 "#).unwrap();
2621 let app = pom::api::router(pool.clone(), config, None);
2622
2623 // Insert DNS check data
2624 let dns_result = DnsCheckResult {
2625 target: "mnw".to_string(),
2626 name: "makenot.work".to_string(),
2627 record_type: pom::types::DnsRecordType::A,
2628 expected: vec!["5.78.144.244".to_string()],
2629 actual: vec!["5.78.144.244".to_string()],
2630 matches: true,
2631 checked_at: chrono::Utc::now().to_rfc3339(),
2632 error: None,
2633 };
2634 db::insert_dns_check(&pool, &dns_result).await.unwrap();
2635
2636 let (status, json) = api_get(&app, "/api/status/mnw").await;
2637 assert_eq!(status, 200);
2638 let dns = json["dns_status"].as_array().unwrap();
2639 assert_eq!(dns.len(), 1);
2640 assert_eq!(dns[0]["name"], "makenot.work");
2641 assert_eq!(dns[0]["matches"], true);
2642 }
2643
2644 #[tokio::test]
2645 async fn api_status_omits_empty_dns_status() {
2646 let pool = db::connect_in_memory().await.unwrap();
2647 let config = test_config();
2648 let app = pom::api::router(pool, config, None);
2649
2650 let (status, json) = api_get(&app, "/api/status/mnw").await;
2651 assert_eq!(status, 200);
2652 assert!(json.get("dns_status").is_none());
2653 }
2654
2655 #[tokio::test]
2656 async fn api_status_includes_whois() {
2657 let pool = db::connect_in_memory().await.unwrap();
2658 let config = test_config();
2659 let app = pom::api::router(pool.clone(), config, None);
2660
2661 let whois_result = WhoisResult {
2662 target: "mnw".to_string(),
2663 domain: "makenot.work".to_string(),
2664 registrar: Some("Namecheap, Inc.".to_string()),
2665 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2666 days_remaining: Some(261),
2667 nameservers: vec!["ns1.example.com".to_string()],
2668 checked_at: chrono::Utc::now().to_rfc3339(),
2669 error: None,
2670 };
2671 db::insert_whois_check(&pool, &whois_result).await.unwrap();
2672
2673 let (status, json) = api_get(&app, "/api/status/mnw").await;
2674 assert_eq!(status, 200);
2675 assert!(json["whois"].is_object());
2676 assert_eq!(json["whois"]["domain"], "makenot.work");
2677 assert_eq!(json["whois"]["days_remaining"], 261);
2678 }
2679
2680 #[tokio::test]
2681 async fn api_status_omits_whois_when_none() {
2682 let pool = db::connect_in_memory().await.unwrap();
2683 let config = test_config();
2684 let app = pom::api::router(pool, config, None);
2685
2686 let (status, json) = api_get(&app, "/api/status/mnw").await;
2687 assert_eq!(status, 200);
2688 assert!(json.get("whois").is_none());
2689 }
2690
2691 // --- DNS/WHOIS config parsing tests ---
2692
2693 #[test]
2694 fn config_with_dns_records_parses() {
2695 let toml_str = r#"
2696 [targets.mnw]
2697 label = "MakeNotWork"
2698
2699 [[targets.mnw.dns]]
2700 name = "makenot.work"
2701 record_type = "A"
2702 expected = ["5.78.144.244"]
2703
2704 [[targets.mnw.dns]]
2705 name = "forums.makenot.work"
2706 record_type = "A"
2707 expected = ["5.78.144.244"]
2708 "#;
2709 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2710 let mnw = config.get_target("mnw").unwrap();
2711 assert_eq!(mnw.dns.len(), 2);
2712 assert_eq!(mnw.dns[0].name, "makenot.work");
2713 assert_eq!(mnw.dns[0].record_type, pom::types::DnsRecordType::A);
2714 assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
2715 assert_eq!(mnw.dns[1].name, "forums.makenot.work");
2716 }
2717
2718 #[test]
2719 fn config_with_whois_parses() {
2720 let toml_str = r#"
2721 [targets.mnw]
2722 label = "MakeNotWork"
2723
2724 [targets.mnw.whois]
2725 domain = "makenot.work"
2726 warn_days = 60
2727 "#;
2728 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2729 let mnw = config.get_target("mnw").unwrap();
2730 let whois = mnw.whois.as_ref().unwrap();
2731 assert_eq!(whois.domain, "makenot.work");
2732 assert_eq!(whois.warn_days, 60);
2733 }
2734
2735 #[test]
2736 fn config_whois_default_warn_days() {
2737 let toml_str = r#"
2738 [targets.mnw]
2739 label = "MakeNotWork"
2740
2741 [targets.mnw.whois]
2742 domain = "makenot.work"
2743 "#;
2744 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2745 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
2746 assert_eq!(whois.warn_days, 30);
2747 }
2748
2749 #[test]
2750 fn config_with_cors_parses() {
2751 let toml_str = r#"
2752 [targets.mnw]
2753 label = "MakeNotWork"
2754
2755 [[targets.mnw.cors]]
2756 url = "https://example.com/bucket/probe"
2757 origin = "https://myapp.com"
2758 method = "PUT"
2759
2760 [[targets.mnw.cors]]
2761 url = "https://example.com/bucket/probe2"
2762 origin = "https://myapp.com"
2763 "#;
2764 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2765 let mnw = config.get_target("mnw").unwrap();
2766 assert_eq!(mnw.cors.len(), 2);
2767 assert_eq!(mnw.cors[0].url, "https://example.com/bucket/probe");
2768 assert_eq!(mnw.cors[0].origin, "https://myapp.com");
2769 assert_eq!(mnw.cors[0].method, "PUT");
2770 // Second entry uses default method
2771 assert_eq!(mnw.cors[1].method, "PUT");
2772 }
2773
2774 #[test]
2775 fn config_no_cors_defaults_to_empty() {
2776 let toml_str = r#"
2777 [targets.mnw]
2778 label = "MakeNotWork"
2779 "#;
2780 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2781 let mnw = config.get_target("mnw").unwrap();
2782 assert!(mnw.cors.is_empty());
2783 }
2784
2785 #[test]
2786 fn config_no_dns_defaults_to_empty() {
2787 let toml_str = r#"
2788 [targets.mnw]
2789 label = "MakeNotWork"
2790 "#;
2791 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
2792 let mnw = config.get_target("mnw").unwrap();
2793 assert!(mnw.dns.is_empty());
2794 assert!(mnw.whois.is_none());
2795 }
2796
2797 // --- Dashboard tests ---
2798
2799 #[tokio::test]
2800 async fn dashboard_enabled_serves_html() {
2801 let pool = db::connect_in_memory().await.unwrap();
2802 let mut config = test_config();
2803 config.serve.dashboard = true;
2804 let app = pom::api::router(pool, config, None);
2805
2806 let (status, body) = get_body(&app, "/").await;
2807 assert_eq!(status, 200);
2808 assert!(body.contains("<!DOCTYPE html>"), "should contain doctype");
2809 assert!(body.contains("PoM"), "should contain PoM title");
2810 }
2811
2812 #[tokio::test]
2813 async fn dashboard_disabled_returns_404() {
2814 let pool = db::connect_in_memory().await.unwrap();
2815 let config = test_config(); // dashboard defaults to false
2816 let app = pom::api::router(pool, config, None);
2817
2818 let req = axum::http::Request::builder()
2819 .uri("/")
2820 .body(Body::empty())
2821 .unwrap();
2822 let resp = app.clone().oneshot(req).await.unwrap();
2823 assert_eq!(resp.status().as_u16(), 404);
2824 }
2825
2826 #[tokio::test]
2827 async fn dashboard_embeds_api_token() {
2828 let pool = db::connect_in_memory().await.unwrap();
2829 let mut config = test_config();
2830 config.serve.dashboard = true;
2831 config.serve.api_token = Some("test-secret-token-42".to_string());
2832 let app = pom::api::router(pool, config, None);
2833
2834 let (status, body) = get_body(&app, "/").await;
2835 assert_eq!(status, 200);
2836 assert!(body.contains("test-secret-token-42"), "should embed the API token");
2837 }
2838
2839 #[tokio::test]
2840 async fn dashboard_shows_mesh_section_when_mesh_enabled() {
2841 let pool = db::connect_in_memory().await.unwrap();
2842 let mut config = test_config();
2843 config.serve.dashboard = true;
2844 let mesh = test_mesh();
2845 let app = pom::api::router(pool, config, Some(mesh));
2846
2847 let (status, body) = get_body(&app, "/").await;
2848 assert_eq!(status, 200);
2849 assert!(body.contains("Peer Mesh"), "should contain mesh section title");
2850 assert!(body.contains("HAS_MESH = true"), "should set HAS_MESH to true");
2851 }
2852
2853 #[tokio::test]
2854 async fn dashboard_no_mesh_section_when_mesh_disabled() {
2855 let pool = db::connect_in_memory().await.unwrap();
2856 let mut config = test_config();
2857 config.serve.dashboard = true;
2858 let app = pom::api::router(pool, config, None);
2859
2860 let (status, body) = get_body(&app, "/").await;
2861 assert_eq!(status, 200);
2862 assert!(body.contains("HAS_MESH = false"), "should set HAS_MESH to false");
2863 }
2864
2865 // --- Per-test detail tracking ---
2866
2867 #[tokio::test]
2868 async fn insert_and_query_test_details() {
2869 let pool = db::connect_in_memory().await.unwrap();
2870
2871 let run = TestRun {
2872 id: None,
2873 target: "mnw".to_string(),
2874 started_at: "2026-03-16T00:00:00Z".to_string(),
2875 finished_at: Some("2026-03-16T00:02:00Z".to_string()),
2876 duration_secs: Some(120),
2877 exit_code: Some(0),
2878 passed: true,
2879 summary: TestSummary {
2880 steps: vec![],
2881 total_passed: Some(3),
2882 total_failed: Some(0),
2883 details: vec![
2884 TestDetail { test_name: "foo::bar".to_string(), passed: true },
2885 TestDetail { test_name: "foo::baz".to_string(), passed: true },
2886 TestDetail { test_name: "foo::qux".to_string(), passed: true },
2887 ],
2888 },
2889 raw_output: String::new(),
2890 filter: None,
2891 };
2892
2893 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
2894 db::insert_test_details(&pool, run_id, &run.summary.details).await.unwrap();
2895
2896 // Verify via regression detection (no previous run = no regressions)
2897 let regressions = db::get_test_regressions(&pool, "mnw", run_id).await.unwrap();
2898 assert!(regressions.is_empty());
2899 }
2900
2901 #[tokio::test]
2902 async fn regression_detection_finds_newly_failing_tests() {
2903 let pool = db::connect_in_memory().await.unwrap();
2904
2905 // First run: all pass
2906 let run1 = TestRun {
2907 id: None,
2908 target: "mnw".to_string(),
2909 started_at: "2026-03-16T00:00:00Z".to_string(),
2910 finished_at: Some("2026-03-16T00:02:00Z".to_string()),
2911 duration_secs: Some(120),
2912 exit_code: Some(0),
2913 passed: true,
2914 summary: TestSummary {
2915 steps: vec![],
2916 total_passed: Some(3),
2917 total_failed: Some(0),
2918 details: vec![
2919 TestDetail { test_name: "foo::bar".to_string(), passed: true },
2920 TestDetail { test_name: "foo::baz".to_string(), passed: true },
2921 TestDetail { test_name: "foo::qux".to_string(), passed: true },
2922 ],
2923 },
2924 raw_output: String::new(),
2925 filter: None,
2926 };
2927
2928 let run1_id = db::insert_test_run(&pool, &run1).await.unwrap();
2929 db::insert_test_details(&pool, run1_id, &run1.summary.details).await.unwrap();
2930
2931 // Second run: foo::baz fails
2932 let run2 = TestRun {
2933 id: None,
2934 target: "mnw".to_string(),
2935 started_at: "2026-03-16T00:05:00Z".to_string(),
2936 finished_at: Some("2026-03-16T00:07:00Z".to_string()),
2937 duration_secs: Some(120),
2938 exit_code: Some(1),
2939 passed: false,
2940 summary: TestSummary {
2941 steps: vec![],
2942 total_passed: Some(2),
2943 total_failed: Some(1),
2944 details: vec![
2945 TestDetail { test_name: "foo::bar".to_string(), passed: true },
2946 TestDetail { test_name: "foo::baz".to_string(), passed: false },
2947 TestDetail { test_name: "foo::qux".to_string(), passed: true },
2948 ],
2949 },
2950 raw_output: String::new(),
2951 filter: None,
2952 };
2953
2954 let run2_id = db::insert_test_run(&pool, &run2).await.unwrap();
2955 db::insert_test_details(&pool, run2_id, &run2.summary.details).await.unwrap();
2956
2957 let regressions = db::get_test_regressions(&pool, "mnw", run2_id).await.unwrap();
2958 assert_eq!(regressions.len(), 1);
2959 assert_eq!(regressions[0], "foo::baz");
2960 }
2961
2962 #[tokio::test]
2963 async fn regression_ignores_already_failing_tests() {
2964 let pool = db::connect_in_memory().await.unwrap();
2965
2966 // Both runs: foo::baz fails
2967 for (i, ts) in ["00:00:00", "00:05:00"].iter().enumerate() {
2968 let run = TestRun {
2969 id: None,
2970 target: "mnw".to_string(),
2971 started_at: format!("2026-03-16T{ts}Z"),
2972 finished_at: None,
2973 duration_secs: Some(120),
2974 exit_code: Some(1),
2975 passed: false,
2976 summary: TestSummary {
2977 steps: vec![],
2978 total_passed: Some(2),
2979 total_failed: Some(1),
2980 details: vec![
2981 TestDetail { test_name: "foo::bar".to_string(), passed: true },
2982 TestDetail { test_name: "foo::baz".to_string(), passed: false },
2983 ],
2984 },
2985 raw_output: String::new(),
2986 filter: None,
2987 };
2988
2989 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
2990 db::insert_test_details(&pool, run_id, &run.summary.details).await.unwrap();
2991
2992 if i == 1 {
2993 // Second run — baz was already failing, not a regression
2994 let regressions = db::get_test_regressions(&pool, "mnw", run_id).await.unwrap();
2995 assert!(regressions.is_empty());
2996 }
2997 }
2998 }
2999
3000 // --- Test duration drift detection ---
3001
3002 #[tokio::test]
3003 async fn test_duration_drift_detected() {
3004 use pom::checks::http::detect_test_duration_drift;
3005
3006 // 10 baseline runs at 60s, 3 recent runs at 120s (2x baseline > 1.5x threshold)
3007 let mut durations: Vec<(String, i64)> = Vec::new();
3008 // Most recent first
3009 for i in 0..3 {
3010 durations.push((format!("2026-03-16T00:{:02}:00Z", 12 - i), 120));
3011 }
3012 for i in 0..10 {
3013 durations.push((format!("2026-03-16T00:{:02}:00Z", 9 - i), 60));
3014 }
3015
3016 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3017 assert!(drift.is_some());
3018 let msg = drift.unwrap();
3019 assert!(msg.contains("drift"), "drift message: {msg}");
3020 }
3021
3022 #[tokio::test]
3023 async fn test_duration_no_drift_when_stable() {
3024 use pom::checks::http::detect_test_duration_drift;
3025
3026 // All runs at ~60s
3027 let mut durations: Vec<(String, i64)> = Vec::new();
3028 for i in 0..13 {
3029 durations.push((format!("2026-03-16T00:{:02}:00Z", 12 - i), 60));
3030 }
3031
3032 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3033 assert!(drift.is_none());
3034 }
3035
3036 #[tokio::test]
3037 async fn test_duration_drift_not_enough_data() {
3038 use pom::checks::http::detect_test_duration_drift;
3039
3040 // Only 5 runs (need 13 for baseline 10 + recent 3)
3041 let durations: Vec<(String, i64)> = (0..5)
3042 .map(|i| (format!("2026-03-16T00:{:02}:00Z", i), 120))
3043 .collect();
3044
3045 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3046 assert!(drift.is_none());
3047 }
3048
3049 #[tokio::test]
3050 async fn get_test_durations_returns_ordered() {
3051 let pool = db::connect_in_memory().await.unwrap();
3052
3053 for (i, secs) in [60, 80, 100].iter().enumerate() {
3054 let run = TestRun {
3055 id: None,
3056 target: "mnw".to_string(),
3057 started_at: format!("2026-03-16T00:{:02}:00Z", i),
3058 finished_at: None,
3059 duration_secs: Some(*secs),
3060 exit_code: Some(0),
3061 passed: true,
3062 summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![] },
3063 raw_output: String::new(),
3064 filter: None,
3065 };
3066 db::insert_test_run(&pool, &run).await.unwrap();
3067 }
3068
3069 let durations = db::get_test_durations(&pool, "mnw", 10).await.unwrap();
3070 assert_eq!(durations.len(), 3);
3071 // Most recent first
3072 assert_eq!(durations[0].1, 100);
3073 assert_eq!(durations[2].1, 60);
3074 }
3075
3076 // --- Uptime percent tests ---
3077
3078 #[tokio::test]
3079 async fn uptime_percent_all_operational() {
3080 let pool = db::connect_in_memory().await.unwrap();
3081
3082 for i in 0..10 {
3083 let snapshot = HealthSnapshot {
3084 id: None,
3085 target: "mnw".to_string(),
3086 status: HealthStatus::Operational,
3087 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3088 response_time_ms: 100,
3089 details: None,
3090 error: None,
3091 };
3092 db::insert_health_check(&pool, &snapshot).await.unwrap();
3093 }
3094
3095 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3096 assert_eq!(pct, Some(100.0));
3097 }
3098
3099 #[tokio::test]
3100 async fn uptime_percent_mixed() {
3101 let pool = db::connect_in_memory().await.unwrap();
3102
3103 // 8 operational
3104 for i in 0..8 {
3105 let snapshot = HealthSnapshot {
3106 id: None,
3107 target: "mnw".to_string(),
3108 status: HealthStatus::Operational,
3109 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3110 response_time_ms: 100,
3111 details: None,
3112 error: None,
3113 };
3114 db::insert_health_check(&pool, &snapshot).await.unwrap();
3115 }
3116
3117 // 2 error
3118 for i in 8..10 {
3119 let snapshot = HealthSnapshot {
3120 id: None,
3121 target: "mnw".to_string(),
3122 status: HealthStatus::Error,
3123 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3124 response_time_ms: 0,
3125 details: None,
3126 error: Some("down".to_string()),
3127 };
3128 db::insert_health_check(&pool, &snapshot).await.unwrap();
3129 }
3130
3131 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3132 assert!(pct.is_some());
3133 let p = pct.unwrap();
3134 assert!((p - 80.0).abs() < 0.01, "expected ~80.0, got {p}");
3135 }
3136
3137 #[tokio::test]
3138 async fn uptime_percent_no_data() {
3139 let pool = db::connect_in_memory().await.unwrap();
3140
3141 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3142 assert_eq!(pct, None);
3143 }
3144
3145 #[tokio::test]
3146 async fn uptime_percent_only_old_data() {
3147 let pool = db::connect_in_memory().await.unwrap();
3148
3149 // Insert checks from 48 hours ago
3150 for i in 0..5 {
3151 let snapshot = HealthSnapshot {
3152 id: None,
3153 target: "mnw".to_string(),
3154 status: HealthStatus::Operational,
3155 checked_at: (chrono::Utc::now() - chrono::Duration::hours(48 + i)).to_rfc3339(),
3156 response_time_ms: 100,
3157 details: None,
3158 error: None,
3159 };
3160 db::insert_health_check(&pool, &snapshot).await.unwrap();
3161 }
3162
3163 // Query last 24h — should find nothing
3164 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3165 assert_eq!(pct, None);
3166 }
3167
3168 // --- Prune test_details orphan cleanup ---
3169
3170 #[tokio::test]
3171 async fn prune_cascades_test_details_with_deleted_run() {
3172 let pool = db::connect_in_memory().await.unwrap();
3173
3174 // Insert an old test run with details
3175 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
3176 let run = TestRun {
3177 id: None,
3178 target: "mnw".to_string(),
3179 started_at: old_time,
3180 finished_at: None,
3181 duration_secs: Some(60),
3182 exit_code: Some(0),
3183 passed: true,
3184 summary: TestSummary {
3185 steps: vec![],
3186 total_passed: Some(2),
3187 total_failed: Some(0),
3188 details: vec![
3189 TestDetail { test_name: "test_a".to_string(), passed: true },
3190 TestDetail { test_name: "test_b".to_string(), passed: true },
3191 ],
3192 },
3193 raw_output: String::new(),
3194 filter: None,
3195 };
3196 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3197 db::insert_test_details(&pool, run_id, &run.summary.details).await.unwrap();
3198
3199 // Verify details exist
3200 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3201 .bind(run_id.0)
3202 .fetch_one(&pool)
3203 .await
3204 .unwrap();
3205 assert_eq!(count.0, 2);
3206
3207 // Prune with 1-day retention — the old run gets deleted, CASCADE removes details
3208 let result = db::prune_old_records(&pool, 1).await.unwrap();
3209 assert_eq!(result.tests, 1);
3210
3211 // Verify details are gone (removed by ON DELETE CASCADE)
3212 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3213 .bind(run_id.0)
3214 .fetch_one(&pool)
3215 .await
3216 .unwrap();
3217 assert_eq!(count.0, 0);
3218 }
3219
3220 #[tokio::test]
3221 async fn prune_cleans_up_orphaned_test_details() {
3222 let pool = db::connect_in_memory().await.unwrap();
3223
3224 // Create a real test run, add details, then delete the run directly to create orphans.
3225 // (FK constraints prevent inserting with a non-existent run_id.)
3226 let run = TestRun {
3227 id: None,
3228 target: "mnw".to_string(),
3229 started_at: chrono::Utc::now().to_rfc3339(),
3230 finished_at: None,
3231 duration_secs: Some(60),
3232 exit_code: Some(0),
3233 passed: true,
3234 summary: TestSummary {
3235 steps: vec![],
3236 total_passed: Some(1),
3237 total_failed: Some(0),
3238 details: vec![TestDetail { test_name: "orphan_test".to_string(), passed: true }],
3239 },
3240 raw_output: String::new(),
3241 filter: None,
3242 };
3243 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3244 db::insert_test_details(&pool, run_id, &run.summary.details).await.unwrap();
3245
3246 // Disable FK enforcement temporarily to delete the run without cascading
3247 sqlx::query("PRAGMA foreign_keys = OFF").execute(&pool).await.unwrap();
3248 sqlx::query("DELETE FROM test_runs WHERE id = ?")
3249 .bind(run_id.0)
3250 .execute(&pool)
3251 .await
3252 .unwrap();
3253 sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await.unwrap();
3254
3255 // Verify orphan exists
3256 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3257 .bind(run_id.0)
3258 .fetch_one(&pool)
3259 .await
3260 .unwrap();
3261 assert_eq!(count.0, 1);
3262
3263 // Prune — orphaned details should be cleaned up by the explicit orphan SQL
3264 let result = db::prune_old_records(&pool, 30).await.unwrap();
3265 assert_eq!(result.test_details, 1);
3266
3267 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3268 .bind(run_id.0)
3269 .fetch_one(&pool)
3270 .await
3271 .unwrap();
3272 assert_eq!(count.0, 0);
3273 }
3274
3275 // --- API test_duration_drift field ---
3276
3277 #[tokio::test]
3278 async fn api_status_target_includes_test_duration_drift() {
3279 let pool = db::connect_in_memory().await.unwrap();
3280 let config = test_config_with_tests();
3281 let app = pom::api::router(pool.clone(), config, None);
3282
3283 // Insert 13 test runs: 10 baseline at 60s, 3 recent at 120s
3284 for i in 0..10 {
3285 let run = TestRun {
3286 id: None,
3287 target: "mnw".to_string(),
3288 started_at: format!("2026-03-16T{:02}:00:00Z", i),
3289 finished_at: None,
3290 duration_secs: Some(60),
3291 exit_code: Some(0),
3292 passed: true,
3293 summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![] },
3294 raw_output: String::new(),
3295 filter: None,
3296 };
3297 db::insert_test_run(&pool, &run).await.unwrap();
3298 }
3299 for i in 10..13 {
3300 let run = TestRun {
3301 id: None,
3302 target: "mnw".to_string(),
3303 started_at: format!("2026-03-16T{:02}:00:00Z", i),
3304 finished_at: None,
3305 duration_secs: Some(120),
3306 exit_code: Some(0),
3307 passed: true,
3308 summary: TestSummary { steps: vec![], total_passed: None, total_failed: None, details: vec![] },
3309 raw_output: String::new(),
3310 filter: None,
3311 };
3312 db::insert_test_run(&pool, &run).await.unwrap();
3313 }
3314
3315 let (status, json) = api_get(&app, "/api/status/mnw").await;
3316 assert_eq!(status, 200);
3317 assert!(json["test_duration_drift"].is_string(), "expected test_duration_drift string, got: {}", json);
3318 let drift_msg = json["test_duration_drift"].as_str().unwrap();
3319 assert!(drift_msg.contains("drift"), "drift message: {drift_msg}");
3320 }
3321