Skip to main content

max / pom

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