Skip to main content

max / makenotwork

136.8 KB · 4478 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 git_sha: None,
25 uptime: Some("5h 30m".to_string()),
26 checks: None,
27 monitoring: None,
28 }),
29 error: None,
30 };
31
32 let id = db::insert_health_check(&pool, &snapshot).await.unwrap();
33 assert!(id > 0);
34
35 let latest = db::get_latest_health(&pool, "test-target").await.unwrap();
36 assert!(latest.is_some());
37 let latest = latest.unwrap();
38 assert_eq!(latest.status, HealthStatus::Operational);
39 assert_eq!(latest.response_time_ms, 150);
40 assert_eq!(latest.details.unwrap().version.unwrap(), "1.0.0");
41 }
42
43 #[tokio::test]
44 async fn health_history_returns_ordered() {
45 let pool = db::connect_in_memory().await.unwrap();
46
47 for i in 0..5 {
48 let snapshot = HealthSnapshot {
49 id: None,
50 target: "mnw".to_string(),
51 status: HealthStatus::Operational,
52 checked_at: format!("2026-03-10T0{i}:00:00Z"),
53 response_time_ms: 100 + i * 10,
54 details: None,
55 error: None,
56 };
57 db::insert_health_check(&pool, &snapshot).await.unwrap();
58 }
59
60 let history = db::get_health_history(&pool, Some("mnw"), 3).await.unwrap();
61 assert_eq!(history.len(), 3);
62 // Most recent first (DESC)
63 assert!(history[0].response_time_ms > history[1].response_time_ms);
64 }
65
66 #[tokio::test]
67 async fn health_history_filters_by_target() {
68 let pool = db::connect_in_memory().await.unwrap();
69
70 for target in &["alpha", "beta"] {
71 let snapshot = HealthSnapshot {
72 id: None,
73 target: target.to_string(),
74 status: HealthStatus::Operational,
75 checked_at: "2026-03-10T00:00:00Z".to_string(),
76 response_time_ms: 100,
77 details: None,
78 error: None,
79 };
80 db::insert_health_check(&pool, &snapshot).await.unwrap();
81 }
82
83 let all = db::get_health_history(&pool, None, 10).await.unwrap();
84 assert_eq!(all.len(), 2);
85
86 let alpha_only = db::get_health_history(&pool, Some("alpha"), 10)
87 .await
88 .unwrap();
89 assert_eq!(alpha_only.len(), 1);
90 assert_eq!(alpha_only[0].target, "alpha");
91 }
92
93 #[tokio::test]
94 async fn test_run_insert_and_query() {
95 let pool = db::connect_in_memory().await.unwrap();
96
97 let run = TestRun {
98 id: None,
99 target: "mnw".to_string(),
100 started_at: "2026-03-10T00:00:00Z".to_string(),
101 finished_at: Some("2026-03-10T00:02:00Z".to_string()),
102 duration_secs: Some(120),
103 exit_code: Some(0),
104 passed: true,
105 summary: TestSummary {
106 steps: vec![
107 StepResult {
108 name: "cargo check".to_string(),
109 passed: true,
110 },
111 StepResult {
112 name: "cargo test --lib".to_string(),
113 passed: true,
114 },
115 ],
116 total_passed: Some(759),
117 total_failed: Some(0),
118 details: vec![],
119 },
120 raw_output: "test output here".to_string(),
121 filter: None,
122 };
123
124 let id = db::insert_test_run(&pool, &run).await.unwrap();
125 assert!(id.0 > 0);
126
127 let latest = db::get_latest_test_run(&pool, "mnw").await.unwrap();
128 assert!(latest.is_some());
129 let latest = latest.unwrap();
130 assert!(latest.passed);
131 assert_eq!(latest.summary.total_passed, Some(759));
132 assert_eq!(latest.summary.steps.len(), 2);
133 assert_eq!(latest.raw_output, "test output here");
134 }
135
136 #[tokio::test]
137 async fn test_history_excludes_other_targets() {
138 let pool = db::connect_in_memory().await.unwrap();
139
140 for target in &["mnw", "other"] {
141 let run = TestRun {
142 id: None,
143 target: target.to_string(),
144 started_at: "2026-03-10T00:00:00Z".to_string(),
145 finished_at: None,
146 duration_secs: None,
147 exit_code: None,
148 passed: true,
149 summary: TestSummary {
150 steps: vec![],
151 total_passed: None,
152 total_failed: None,
153 details: vec![],
154 },
155 raw_output: String::new(),
156 filter: None,
157 };
158 db::insert_test_run(&pool, &run).await.unwrap();
159 }
160
161 let mnw_only = db::get_test_history(&pool, Some("mnw"), 10).await.unwrap();
162 assert_eq!(mnw_only.len(), 1);
163 }
164
165 #[tokio::test]
166 async fn prune_removes_old_records() {
167 let pool = db::connect_in_memory().await.unwrap();
168
169 // Insert an old health check (60 days ago)
170 let old = HealthSnapshot {
171 id: None,
172 target: "mnw".to_string(),
173 status: HealthStatus::Operational,
174 checked_at: (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339(),
175 response_time_ms: 100,
176 details: None,
177 error: None,
178 };
179 db::insert_health_check(&pool, &old).await.unwrap();
180
181 // Insert a recent one
182 let recent = HealthSnapshot {
183 id: None,
184 target: "mnw".to_string(),
185 status: HealthStatus::Operational,
186 checked_at: chrono::Utc::now().to_rfc3339(),
187 response_time_ms: 100,
188 details: None,
189 error: None,
190 };
191 db::insert_health_check(&pool, &recent).await.unwrap();
192
193 let result = db::prune_old_records(&pool, 30).await.unwrap();
194 assert_eq!(result.health, 1);
195
196 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
197 assert_eq!(remaining.len(), 1);
198 }
199
200 #[tokio::test]
201 async fn parse_ci_output_integration() {
202 use pom::checks::parse;
203
204 let output = r"
205 ========================================
206 cargo check
207 ========================================
208
209 Finished `dev` profile
210
211 ========================================
212 cargo test --lib
213 ========================================
214
215 running 45 tests
216 test result: ok. 45 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.3s
217
218 ========================================
219 CI Summary
220 ========================================
221
222 PASS cargo check
223 PASS cargo test --lib
224 PASS cargo clippy
225
226 All steps passed.
227 ";
228
229 let summary = parse::parse_ci_output(output);
230 assert_eq!(summary.steps.len(), 3);
231 assert!(summary.steps.iter().all(|s| s.passed));
232 assert_eq!(summary.total_passed, Some(45));
233 assert_eq!(summary.total_failed, Some(0));
234 }
235
236 #[tokio::test]
237 async fn peer_identity_first_wins() {
238 let pool = db::connect_in_memory().await.unwrap();
239
240 db::store_peer_identity(&pool, "astra", "uuid-1")
241 .await
242 .unwrap();
243 // Second insert with different ID should be ignored (INSERT OR IGNORE)
244 db::store_peer_identity(&pool, "astra", "uuid-2")
245 .await
246 .unwrap();
247
248 let stored = db::get_peer_identity(&pool, "astra").await.unwrap();
249 assert_eq!(stored, Some("uuid-1".to_string()));
250 }
251
252 #[tokio::test]
253 async fn peer_heartbeat_insert_and_query() {
254 let pool = db::connect_in_memory().await.unwrap();
255
256 db::insert_peer_heartbeat(&pool, "astra", "online", 42)
257 .await
258 .unwrap();
259 db::insert_peer_heartbeat(&pool, "astra", "online", 55)
260 .await
261 .unwrap();
262 db::insert_peer_heartbeat(&pool, "astra", "missing", 0)
263 .await
264 .unwrap();
265
266 let history = db::get_peer_heartbeat_history(&pool, "astra", 10)
267 .await
268 .unwrap();
269 assert_eq!(history.len(), 3);
270 // Most recent first
271 assert_eq!(history[0].status, "missing");
272 assert_eq!(history[1].latency_ms, 55);
273 }
274
275 // API endpoint tests
276
277 fn test_config() -> pom::config::Config {
278 toml::from_str(
279 r#"
280 [targets.mnw]
281 label = "MakeNotWork"
282 [targets.mnw.health]
283 url = "https://makenot.work/health"
284 "#,
285 )
286 .unwrap()
287 }
288
289 /// Build a GET request carrying a `ConnectInfo<SocketAddr>` extension, the real
290 /// server injects it via `into_make_service_with_connect_info`, but `oneshot`
291 /// does not, and the per-IP rate-limit layer extracts it.
292 fn get_req(path: &str) -> axum::http::Request<Body> {
293 let mut req = axum::http::Request::builder()
294 .uri(path)
295 .body(Body::empty())
296 .unwrap();
297 req.extensions_mut()
298 .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
299 [127, 0, 0, 1],
300 41000,
301 ))));
302 req
303 }
304
305 /// GET a path and return (status_code, body_string), for HTML responses.
306 async fn get_body(app: &axum::Router, path: &str) -> (u16, String) {
307 let resp = app.clone().oneshot(get_req(path)).await.unwrap();
308 let status = resp.status().as_u16();
309 let body = resp.into_body().collect().await.unwrap().to_bytes();
310 (status, String::from_utf8_lossy(&body).into_owned())
311 }
312
313 fn test_mesh() -> pom::peer::SharedMeshState {
314 let info = pom::peer::InstanceInfo {
315 id: "test-uuid".to_string(),
316 name: "test-node".to_string(),
317 version: "0.1.0".to_string(),
318 targets: vec!["mnw".to_string()],
319 started_at: "2026-03-10T00:00:00Z".to_string(),
320 };
321 pom::peer::new_mesh_state(info, &HashMap::new())
322 }
323
324 async fn api_get(app: &axum::Router, path: &str) -> (u16, serde_json::Value) {
325 let resp = app.clone().oneshot(get_req(path)).await.unwrap();
326 let status = resp.status().as_u16();
327 let body = resp.into_body().collect().await.unwrap().to_bytes();
328 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
329 (status, json)
330 }
331
332 #[tokio::test]
333 async fn api_status_returns_targets() {
334 let pool = db::connect_in_memory().await.unwrap();
335 let config = test_config();
336 let app = pom::api::router(pool.clone(), config, None);
337
338 // Insert a health check so there's data
339 let snapshot = HealthSnapshot {
340 id: None,
341 target: "mnw".to_string(),
342 status: HealthStatus::Operational,
343 checked_at: "2026-03-10T00:00:00Z".to_string(),
344 response_time_ms: 120,
345 details: None,
346 error: None,
347 };
348 db::insert_health_check(&pool, &snapshot).await.unwrap();
349
350 let (status, json) = api_get(&app, "/api/status").await;
351 assert_eq!(status, 200);
352 assert!(json["targets"]["mnw"].is_object());
353 assert_eq!(json["targets"]["mnw"]["label"], "MakeNotWork");
354 assert_eq!(json["targets"]["mnw"]["latest"]["status"], "operational");
355 assert_eq!(json["targets"]["mnw"]["latest"]["response_time_ms"], 120);
356 }
357
358 #[tokio::test]
359 async fn api_status_target_not_found() {
360 let pool = db::connect_in_memory().await.unwrap();
361 let config = test_config();
362 let app = pom::api::router(pool, config, None);
363
364 let (status, json) = api_get(&app, "/api/status/nonexistent").await;
365 assert_eq!(status, 404);
366 assert!(json["error"].as_str().unwrap().contains("unknown target"));
367 }
368
369 #[tokio::test]
370 async fn api_peer_info_returns_instance() {
371 let pool = db::connect_in_memory().await.unwrap();
372 let config = test_config();
373 let mesh = test_mesh();
374 let app = pom::api::router(pool, config, Some(mesh));
375
376 let (status, json) = api_get(&app, "/api/peer/info").await;
377 assert_eq!(status, 200);
378 assert_eq!(json["id"], "test-uuid");
379 assert_eq!(json["name"], "test-node");
380 }
381
382 #[tokio::test]
383 async fn api_peer_info_disabled_without_mesh() {
384 let pool = db::connect_in_memory().await.unwrap();
385 let config = test_config();
386 let app = pom::api::router(pool, config, None);
387
388 let (status, json) = api_get(&app, "/api/peer/info").await;
389 assert_eq!(status, 503);
390 assert!(json["error"].as_str().unwrap().contains("not enabled"));
391 }
392
393 #[tokio::test]
394 async fn api_mesh_view_includes_self() {
395 let pool = db::connect_in_memory().await.unwrap();
396 let config = test_config();
397 let mesh = test_mesh();
398 let app = pom::api::router(pool, config, Some(mesh));
399
400 let (status, json) = api_get(&app, "/api/mesh").await;
401 assert_eq!(status, 200);
402 assert!(json["instances"]["test-node"].is_object());
403 assert_eq!(
404 json["instances"]["test-node"]["instance"]["id"],
405 "test-uuid"
406 );
407 }
408
409 // Migration tests
410
411 #[tokio::test]
412 async fn migration_fresh_db_reaches_latest_version() {
413 // A fresh in-memory DB should run all migrations and reach the latest version.
414 let pool = db::connect_in_memory().await.unwrap();
415 let version = db::get_schema_version(&pool).await.unwrap();
416 assert_eq!(version, 15);
417
418 // Verify the schema_version table has entries for each migration
419 let rows = sqlx::query_as::<_, (i64, String)>(
420 "SELECT version, description FROM schema_version ORDER BY version",
421 )
422 .fetch_all(&pool)
423 .await
424 .unwrap();
425 assert_eq!(rows.len(), 15);
426 assert_eq!(rows[0].0, 1);
427 assert_eq!(rows[0].1, "initial schema");
428 assert_eq!(rows[1].0, 2);
429 assert_eq!(rows[1].1, "add alerts table");
430 assert_eq!(rows[2].0, 3);
431 assert_eq!(rows[2].1, "add tls_checks table");
432 assert_eq!(rows[3].0, 4);
433 assert_eq!(rows[3].1, "add incidents table");
434 assert_eq!(rows[4].0, 5);
435 assert_eq!(rows[4].1, "add route_checks table");
436 assert_eq!(rows[5].0, 6);
437 assert_eq!(rows[5].1, "add dns_checks and whois_checks tables");
438 assert_eq!(rows[6].0, 7);
439 assert_eq!(rows[6].1, "add test_details table");
440 assert_eq!(rows[7].0, 8);
441 assert_eq!(rows[7].1, "add cors_checks table");
442 assert_eq!(rows[8].0, 9);
443 assert_eq!(rows[8].1, "add backup_checks table");
444 assert_eq!(rows[9].0, 10);
445 assert_eq!(rows[9].1, "add pending_alerts retry queue");
446 assert_eq!(rows[10].0, 11);
447 assert_eq!(rows[10].1, "add scan_pipeline_checks table");
448 assert_eq!(rows[11].0, 12);
449 assert_eq!(rows[11].1, "add systemd_checks table");
450 assert_eq!(rows[12].0, 13);
451 assert_eq!(rows[12].1, "add synckit_fleet_checks table");
452 assert_eq!(rows[13].0, 14);
453 assert_eq!(rows[13].1, "record per-trust-store results on tls_checks");
454 assert_eq!(rows[14].0, 15);
455 assert_eq!(rows[14].1, "add ca_bundle_checks table");
456
457 // Verify actual tables were created by inserting data
458 let snapshot = HealthSnapshot {
459 id: None,
460 target: "test".to_string(),
461 status: HealthStatus::Operational,
462 checked_at: "2026-03-11T00:00:00Z".to_string(),
463 response_time_ms: 50,
464 details: None,
465 error: None,
466 };
467 let id = db::insert_health_check(&pool, &snapshot).await.unwrap();
468 assert!(id > 0);
469 }
470
471 #[tokio::test]
472 async fn migration_already_current_is_idempotent() {
473 // Running migrations on an already-migrated DB should be a no-op.
474 let pool = db::connect_in_memory().await.unwrap();
475 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15);
476
477 // Run migrations again
478 db::run_migrations(&pool).await.unwrap();
479 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15);
480
481 // schema_version should still have exactly thirteen entries (not duplicated)
482 let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version")
483 .fetch_one(&pool)
484 .await
485 .unwrap();
486 assert_eq!(count.0, 15);
487 }
488
489 #[tokio::test]
490 async fn migration_detects_pre_migration_database() {
491 // Simulate a pre-migration database: create tables manually without schema_version.
492 let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
493 let pool = sqlx::sqlite::SqlitePoolOptions::new()
494 .max_connections(1)
495 .connect_with(opts)
496 .await
497 .unwrap();
498
499 // Create the old-style tables directly (as init_schema used to do)
500 sqlx::query(
501 "CREATE TABLE health_checks (
502 id INTEGER PRIMARY KEY AUTOINCREMENT,
503 target TEXT NOT NULL,
504 status TEXT NOT NULL,
505 checked_at TEXT NOT NULL,
506 response_time_ms INTEGER NOT NULL,
507 details_json TEXT,
508 error TEXT
509 )",
510 )
511 .execute(&pool)
512 .await
513 .unwrap();
514
515 sqlx::query(
516 "CREATE TABLE test_runs (
517 id INTEGER PRIMARY KEY AUTOINCREMENT,
518 target TEXT NOT NULL,
519 started_at TEXT NOT NULL,
520 finished_at TEXT,
521 duration_secs INTEGER,
522 exit_code INTEGER,
523 passed INTEGER NOT NULL,
524 summary_json TEXT NOT NULL,
525 raw_output TEXT NOT NULL,
526 filter TEXT
527 )",
528 )
529 .execute(&pool)
530 .await
531 .unwrap();
532
533 // Insert some existing data to verify it's preserved
534 sqlx::query(
535 "INSERT INTO health_checks (target, status, checked_at, response_time_ms)
536 VALUES ('mnw', 'operational', '2026-03-10T00:00:00Z', 100)",
537 )
538 .execute(&pool)
539 .await
540 .unwrap();
541
542 // Now run migrations, should detect existing tables, stamp as v1, then run v2..v15
543 db::run_migrations(&pool).await.unwrap();
544
545 // Version should be 15 (stamped v1 + ran v2..v15)
546 assert_eq!(db::get_schema_version(&pool).await.unwrap(), 15);
547
548 // Description should indicate pre-existing
549 let row =
550 sqlx::query_as::<_, (String,)>("SELECT description FROM schema_version WHERE version = 1")
551 .fetch_one(&pool)
552 .await
553 .unwrap();
554 assert!(row.0.contains("pre-existing"));
555
556 // Existing data should be preserved
557 let history = db::get_health_history(&pool, Some("mnw"), 10)
558 .await
559 .unwrap();
560 assert_eq!(history.len(), 1);
561 assert_eq!(history[0].response_time_ms, 100);
562 }
563
564 // MCP tool tests
565
566 fn test_server(pool: sqlx::SqlitePool) -> PomServer {
567 PomServer::new(pool, test_config())
568 }
569
570 #[tokio::test]
571 async fn tool_get_status_with_data() {
572 let pool = db::connect_in_memory().await.unwrap();
573 let server = test_server(pool.clone());
574
575 // Insert health + test data
576 let snapshot = HealthSnapshot {
577 id: None,
578 target: "mnw".to_string(),
579 status: HealthStatus::Operational,
580 checked_at: "2026-03-10T00:00:00Z".to_string(),
581 response_time_ms: 95,
582 details: Some(HealthDetails {
583 version: Some("2.1.0".to_string()),
584 git_sha: None,
585 uptime: Some("3d".to_string()),
586 checks: None,
587 monitoring: None,
588 }),
589 error: None,
590 };
591 db::insert_health_check(&pool, &snapshot).await.unwrap();
592
593 let run = TestRun {
594 id: None,
595 target: "mnw".to_string(),
596 started_at: "2026-03-10T00:00:00Z".to_string(),
597 finished_at: Some("2026-03-10T00:01:00Z".to_string()),
598 duration_secs: Some(60),
599 exit_code: Some(0),
600 passed: true,
601 summary: TestSummary {
602 steps: vec![StepResult {
603 name: "cargo test".to_string(),
604 passed: true,
605 }],
606 total_passed: Some(100),
607 total_failed: Some(0),
608 details: vec![],
609 },
610 raw_output: "all good".to_string(),
611 filter: None,
612 };
613 db::insert_test_run(&pool, &run).await.unwrap();
614
615 let result = server.get_status_impl().await.unwrap();
616 assert!(result.contains("## mnw (MakeNotWork)"));
617 assert!(result.contains("operational"));
618 assert!(result.contains("95ms"));
619 assert!(result.contains("Version: 2.1.0"));
620 assert!(result.contains("Uptime: 3d"));
621 assert!(result.contains("PASSED"));
622 assert!(result.contains("100 passed, 0 failed"));
623 assert!(result.contains("PASS cargo test"));
624 }
625
626 #[tokio::test]
627 async fn tool_get_status_no_data() {
628 let pool = db::connect_in_memory().await.unwrap();
629 let server = test_server(pool);
630
631 let result = server.get_status_impl().await.unwrap();
632 assert!(result.contains("Health: no data"));
633 assert!(result.contains("Tests: no data"));
634 }
635
636 #[tokio::test]
637 async fn tool_get_status_no_targets() {
638 let pool = db::connect_in_memory().await.unwrap();
639 let config: pom::config::Config = toml::from_str("").unwrap();
640 let server = PomServer::new(pool, config);
641
642 let result = server.get_status_impl().await.unwrap();
643 assert_eq!(result, "No targets configured.");
644 }
645
646 #[tokio::test]
647 async fn tool_list_targets() {
648 let pool = db::connect_in_memory().await.unwrap();
649 let server = test_server(pool);
650
651 let result = server.list_targets_impl().await.unwrap();
652 let targets: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
653 assert_eq!(targets.len(), 1);
654 assert_eq!(targets[0]["name"], "mnw");
655 assert_eq!(targets[0]["label"], "MakeNotWork");
656 assert_eq!(targets[0]["has_health"], true);
657 assert_eq!(targets[0]["has_tests"], false); // test_config has no tests section
658 }
659
660 #[tokio::test]
661 async fn tool_health_history_with_data() {
662 let pool = db::connect_in_memory().await.unwrap();
663 let server = test_server(pool.clone());
664
665 for i in 0..3 {
666 let snapshot = HealthSnapshot {
667 id: None,
668 target: "mnw".to_string(),
669 status: HealthStatus::Operational,
670 checked_at: format!("2026-03-10T0{i}:00:00Z"),
671 response_time_ms: 100 + i * 10,
672 details: None,
673 error: None,
674 };
675 db::insert_health_check(&pool, &snapshot).await.unwrap();
676 }
677
678 let params = pom::tools::health::HealthHistoryParams {
679 target: Some("mnw".to_string()),
680 limit: Some(2),
681 };
682 let result = server.health_history_impl(params).await.unwrap();
683 let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
684 assert_eq!(history.len(), 2);
685 }
686
687 #[tokio::test]
688 async fn tool_health_history_empty() {
689 let pool = db::connect_in_memory().await.unwrap();
690 let server = test_server(pool);
691
692 let params = pom::tools::health::HealthHistoryParams {
693 target: None,
694 limit: None,
695 };
696 let result = server.health_history_impl(params).await.unwrap();
697 assert_eq!(result, "No health check history.");
698 }
699
700 #[tokio::test]
701 async fn tool_health_history_default_limit() {
702 let pool = db::connect_in_memory().await.unwrap();
703 let server = test_server(pool.clone());
704
705 for i in 0..15 {
706 let snapshot = HealthSnapshot {
707 id: None,
708 target: "mnw".to_string(),
709 status: HealthStatus::Operational,
710 checked_at: format!("2026-03-10T{i:02}:00:00Z"),
711 response_time_ms: 100,
712 details: None,
713 error: None,
714 };
715 db::insert_health_check(&pool, &snapshot).await.unwrap();
716 }
717
718 let params = pom::tools::health::HealthHistoryParams {
719 target: None,
720 limit: None, // should default to 10
721 };
722 let result = server.health_history_impl(params).await.unwrap();
723 let history: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
724 assert_eq!(history.len(), 10);
725 }
726
727 #[tokio::test]
728 async fn tool_check_health_unknown_target() {
729 let pool = db::connect_in_memory().await.unwrap();
730 let server = test_server(pool);
731
732 let params = pom::tools::health::CheckHealthParams {
733 target: Some("nonexistent".to_string()),
734 };
735 let result = server.check_health_impl(params).await.unwrap();
736 assert_eq!(result, "Unknown target: nonexistent");
737 }
738
739 #[tokio::test]
740 async fn tool_test_history_strips_raw_output() {
741 let pool = db::connect_in_memory().await.unwrap();
742 let server = test_server(pool.clone());
743
744 let run = TestRun {
745 id: None,
746 target: "mnw".to_string(),
747 started_at: "2026-03-10T00:00:00Z".to_string(),
748 finished_at: None,
749 duration_secs: None,
750 exit_code: None,
751 passed: true,
752 summary: TestSummary {
753 steps: vec![],
754 total_passed: None,
755 total_failed: None,
756 details: vec![],
757 },
758 raw_output: "HUGE OUTPUT THAT SHOULD NOT APPEAR".to_string(),
759 filter: None,
760 };
761 db::insert_test_run(&pool, &run).await.unwrap();
762
763 let params = pom::tools::tests::TestHistoryParams {
764 target: Some("mnw".to_string()),
765 limit: None,
766 };
767 let result = server.test_history_impl(params).await.unwrap();
768 assert!(!result.contains("HUGE OUTPUT"));
769 assert!(result.contains("mnw"));
770 }
771
772 #[tokio::test]
773 async fn tool_test_history_empty() {
774 let pool = db::connect_in_memory().await.unwrap();
775 let server = test_server(pool);
776
777 let params = pom::tools::tests::TestHistoryParams {
778 target: None,
779 limit: None,
780 };
781 let result = server.test_history_impl(params).await.unwrap();
782 assert_eq!(result, "No test run history.");
783 }
784
785 #[tokio::test]
786 async fn tool_last_test_output_returns_raw() {
787 let pool = db::connect_in_memory().await.unwrap();
788 let server = test_server(pool.clone());
789
790 let run = TestRun {
791 id: None,
792 target: "mnw".to_string(),
793 started_at: "2026-03-10T00:00:00Z".to_string(),
794 finished_at: None,
795 duration_secs: None,
796 exit_code: None,
797 passed: true,
798 summary: TestSummary {
799 steps: vec![],
800 total_passed: None,
801 total_failed: None,
802 details: vec![],
803 },
804 raw_output: "running 42 tests\ntest result: ok".to_string(),
805 filter: None,
806 };
807 db::insert_test_run(&pool, &run).await.unwrap();
808
809 let params = pom::tools::tests::LastTestOutputParams {
810 target: "mnw".to_string(),
811 };
812 let result = server.last_test_output_impl(params).await.unwrap();
813 assert_eq!(result, "running 42 tests\ntest result: ok");
814 }
815
816 #[tokio::test]
817 async fn tool_last_test_output_no_runs() {
818 let pool = db::connect_in_memory().await.unwrap();
819 let server = test_server(pool);
820
821 let params = pom::tools::tests::LastTestOutputParams {
822 target: "mnw".to_string(),
823 };
824 let result = server.last_test_output_impl(params).await.unwrap();
825 assert_eq!(result, "No test runs found for target 'mnw'");
826 }
827
828 #[tokio::test]
829 async fn tool_run_tests_unknown_target() {
830 let pool = db::connect_in_memory().await.unwrap();
831 let server = test_server(pool);
832
833 let params = pom::tools::tests::RunTestsParams {
834 target: "nonexistent".to_string(),
835 filter: None,
836 };
837 let result = server.run_tests_impl(params).await;
838 assert!(result.is_err());
839 assert!(result.unwrap_err().to_string().contains("Unknown target"));
840 }
841
842 #[tokio::test]
843 async fn tool_run_tests_no_test_config() {
844 let pool = db::connect_in_memory().await.unwrap();
845 let server = test_server(pool); // test_config has mnw with health but no tests
846
847 let params = pom::tools::tests::RunTestsParams {
848 target: "mnw".to_string(),
849 filter: None,
850 };
851 let result = server.run_tests_impl(params).await;
852 assert!(result.is_err());
853 assert!(
854 result
855 .unwrap_err()
856 .to_string()
857 .contains("no test configuration")
858 );
859 }
860
861 // Alert tests
862
863 #[tokio::test]
864 async fn migration_v2_creates_alerts_table() {
865 let pool = db::connect_in_memory().await.unwrap();
866 let version = db::get_schema_version(&pool).await.unwrap();
867 assert_eq!(version, 15);
868
869 // Verify alerts table exists by inserting
870 let id = db::insert_alert(
871 &pool,
872 "mnw",
873 "health",
874 Some("operational"),
875 Some("error"),
876 None,
877 )
878 .await
879 .unwrap();
880 assert!(id > 0);
881 }
882
883 #[tokio::test]
884 async fn alert_insert_and_query() {
885 let pool = db::connect_in_memory().await.unwrap();
886
887 db::insert_alert(
888 &pool,
889 "health:mnw",
890 "health",
891 Some("operational"),
892 Some("error"),
893 Some("connection refused"),
894 )
895 .await
896 .unwrap();
897
898 let latest = db::get_latest_alert_for_target(&pool, "health:mnw")
899 .await
900 .unwrap();
901 assert!(latest.is_some());
902 let row = latest.unwrap();
903 assert_eq!(row.target, "health:mnw");
904 assert_eq!(row.alert_type, "health");
905 assert_eq!(row.from_status.as_deref(), Some("operational"));
906 assert_eq!(row.to_status.as_deref(), Some("error"));
907 assert_eq!(row.error.as_deref(), Some("connection refused"));
908 }
909
910 #[tokio::test]
911 async fn alert_query_returns_none_for_unknown_target() {
912 let pool = db::connect_in_memory().await.unwrap();
913
914 let latest = db::get_latest_alert_for_target(&pool, "nonexistent")
915 .await
916 .unwrap();
917 assert!(latest.is_none());
918 }
919
920 #[tokio::test]
921 async fn prune_removes_old_alerts() {
922 let pool = db::connect_in_memory().await.unwrap();
923
924 // Insert an old alert directly with old timestamp
925 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
926 sqlx::query("INSERT INTO alerts (target, alert_type, sent_at) VALUES (?, ?, ?)")
927 .bind("mnw")
928 .bind("health")
929 .bind(&old_time)
930 .execute(&pool)
931 .await
932 .unwrap();
933
934 // Insert a recent alert
935 db::insert_alert(&pool, "mnw", "health", None, None, None)
936 .await
937 .unwrap();
938
939 let result = db::prune_old_records(&pool, 30).await.unwrap();
940 assert_eq!(result.alerts, 1);
941
942 // Recent alert should remain
943 let latest = db::get_latest_alert_for_target(&pool, "mnw").await.unwrap();
944 assert!(latest.is_some());
945 }
946
947 // TLS check tests
948
949 #[tokio::test]
950 async fn migration_v3_creates_tls_checks_table() {
951 let pool = db::connect_in_memory().await.unwrap();
952 let version = db::get_schema_version(&pool).await.unwrap();
953 assert_eq!(version, 15);
954
955 // Verify tls_checks table exists by inserting
956 let status = pom::types::TlsStatus {
957 target: "mnw".to_string(),
958 host: "makenot.work".to_string(),
959 port: 443,
960 valid: true,
961 days_remaining: 47,
962 not_before: "2026-01-10T00:00:00Z".to_string(),
963 not_after: "2026-04-27T00:00:00Z".to_string(),
964 subject: "CN=makenot.work".to_string(),
965 issuer: "CN=Let's Encrypt".to_string(),
966 checked_at: "2026-03-11T00:00:00Z".to_string(),
967 error: None,
968 webpki_trusted: true,
969 platform_trusted: true,
970 webpki_error: None,
971 platform_error: None,
972 };
973 let id = db::insert_tls_check(&pool, &status).await.unwrap();
974 assert!(id > 0);
975 }
976
977 #[tokio::test]
978 async fn tls_check_insert_and_query() {
979 let pool = db::connect_in_memory().await.unwrap();
980
981 let status = pom::types::TlsStatus {
982 target: "mnw".to_string(),
983 host: "makenot.work".to_string(),
984 port: 443,
985 valid: true,
986 days_remaining: 47,
987 not_before: "2026-01-10T00:00:00Z".to_string(),
988 not_after: "2026-04-27T00:00:00Z".to_string(),
989 subject: "CN=makenot.work".to_string(),
990 issuer: "CN=Let's Encrypt".to_string(),
991 checked_at: "2026-03-11T00:00:00Z".to_string(),
992 error: None,
993 webpki_trusted: true,
994 platform_trusted: true,
995 webpki_error: None,
996 platform_error: None,
997 };
998 db::insert_tls_check(&pool, &status).await.unwrap();
999
1000 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap();
1001 assert!(latest.is_some());
1002 let row = latest.unwrap();
1003 assert_eq!(row.host, "makenot.work");
1004 assert!(row.valid);
1005 assert_eq!(row.days_remaining, 47);
1006 assert_eq!(row.subject, "CN=makenot.work");
1007 assert!(row.error.is_none());
1008 }
1009
1010 #[tokio::test]
1011 async fn tls_check_error_stored() {
1012 let pool = db::connect_in_memory().await.unwrap();
1013
1014 let status = pom::types::TlsStatus {
1015 target: "mnw".to_string(),
1016 host: "makenot.work".to_string(),
1017 port: 443,
1018 valid: false,
1019 days_remaining: 0,
1020 not_before: String::new(),
1021 not_after: String::new(),
1022 subject: String::new(),
1023 issuer: String::new(),
1024 checked_at: "2026-03-11T00:00:00Z".to_string(),
1025 error: Some("connection refused".to_string()),
1026 webpki_trusted: false,
1027 platform_trusted: false,
1028 webpki_error: None,
1029 platform_error: None,
1030 };
1031 db::insert_tls_check(&pool, &status).await.unwrap();
1032
1033 let latest = db::get_latest_tls_check(&pool, "mnw")
1034 .await
1035 .unwrap()
1036 .unwrap();
1037 assert!(!latest.valid);
1038 assert_eq!(latest.error.as_deref(), Some("connection refused"));
1039 }
1040
1041 #[tokio::test]
1042 async fn prune_removes_old_tls_checks() {
1043 let pool = db::connect_in_memory().await.unwrap();
1044
1045 // Insert old TLS check
1046 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
1047 sqlx::query(
1048 "INSERT INTO tls_checks (target, host, valid, days_remaining, not_before, not_after, subject, issuer, checked_at)
1049 VALUES (?, ?, ?, ?, '', '', '', '', ?)",
1050 )
1051 .bind("mnw")
1052 .bind("makenot.work")
1053 .bind(true)
1054 .bind(47)
1055 .bind(&old_time)
1056 .execute(&pool)
1057 .await
1058 .unwrap();
1059
1060 // Insert recent TLS check
1061 let status = pom::types::TlsStatus {
1062 target: "mnw".to_string(),
1063 host: "makenot.work".to_string(),
1064 port: 443,
1065 valid: true,
1066 days_remaining: 47,
1067 not_before: String::new(),
1068 not_after: String::new(),
1069 subject: String::new(),
1070 issuer: String::new(),
1071 checked_at: chrono::Utc::now().to_rfc3339(),
1072 error: None,
1073 webpki_trusted: true,
1074 platform_trusted: true,
1075 webpki_error: None,
1076 platform_error: None,
1077 };
1078 db::insert_tls_check(&pool, &status).await.unwrap();
1079
1080 let result = db::prune_old_records(&pool, 30).await.unwrap();
1081 assert_eq!(result.tls, 1);
1082
1083 // Recent check should remain
1084 let latest = db::get_latest_tls_check(&pool, "mnw").await.unwrap();
1085 assert!(latest.is_some());
1086 }
1087
1088 #[tokio::test]
1089 async fn api_status_target_includes_tls() {
1090 let pool = db::connect_in_memory().await.unwrap();
1091
1092 // Config with TLS
1093 let config: pom::config::Config = toml::from_str(
1094 r#"
1095 [targets.mnw]
1096 label = "MakeNotWork"
1097 [targets.mnw.health]
1098 url = "https://makenot.work/health"
1099 [targets.mnw.tls]
1100 host = "makenot.work"
1101 "#,
1102 )
1103 .unwrap();
1104 let app = pom::api::router(pool.clone(), config, None);
1105
1106 // Insert TLS check data
1107 let status = pom::types::TlsStatus {
1108 target: "mnw".to_string(),
1109 host: "makenot.work".to_string(),
1110 port: 443,
1111 valid: true,
1112 days_remaining: 47,
1113 not_before: "2026-01-10T00:00:00Z".to_string(),
1114 not_after: "2026-04-27T00:00:00Z".to_string(),
1115 subject: "CN=makenot.work".to_string(),
1116 issuer: "CN=Let's Encrypt".to_string(),
1117 checked_at: "2026-03-11T00:00:00Z".to_string(),
1118 error: None,
1119 webpki_trusted: true,
1120 platform_trusted: true,
1121 webpki_error: None,
1122 platform_error: None,
1123 };
1124 db::insert_tls_check(&pool, &status).await.unwrap();
1125
1126 let (http_status, json) = api_get(&app, "/api/status/mnw").await;
1127 assert_eq!(http_status, 200);
1128 assert!(json["tls"].is_object());
1129 assert_eq!(json["tls"]["host"], "makenot.work");
1130 assert_eq!(json["tls"]["days_remaining"], 47);
1131 assert_eq!(json["tls"]["valid"], true);
1132 }
1133
1134 #[tokio::test]
1135 async fn api_status_target_no_tls_omits_field() {
1136 let pool = db::connect_in_memory().await.unwrap();
1137 let config = test_config(); // no TLS config
1138 let app = pom::api::router(pool, config, None);
1139
1140 let (http_status, json) = api_get(&app, "/api/status/mnw").await;
1141 assert_eq!(http_status, 200);
1142 // tls field should be absent (skip_serializing_if)
1143 assert!(json.get("tls").is_none());
1144 }
1145
1146 #[tokio::test]
1147 async fn config_with_tls_parses() {
1148 let toml_str = r#"
1149 [targets.mnw]
1150 label = "MakeNotWork"
1151 [targets.mnw.tls]
1152 host = "makenot.work"
1153 port = 8443
1154 warn_days = 30
1155 "#;
1156 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
1157 let mnw = config.get_target("mnw").unwrap();
1158 let tls = mnw.tls.as_ref().unwrap();
1159 assert_eq!(tls.host, "makenot.work");
1160 assert_eq!(tls.port, 8443);
1161 assert_eq!(tls.warn_days, 30);
1162 }
1163
1164 #[tokio::test]
1165 async fn config_with_alerts_parses() {
1166 let toml = r#"
1167 [targets.mnw]
1168 label = "MakeNotWork"
1169 [targets.mnw.health]
1170 url = "https://makenot.work/health"
1171
1172 [alerts]
1173 postmark_token = "test-token-123"
1174 to = "pom-alerts@makenot.work"
1175 cooldown_secs = 120
1176 "#;
1177 let config: pom::config::Config = toml::from_str(toml).unwrap();
1178 let alerts = config.alerts.unwrap();
1179 assert_eq!(alerts.postmark_token.as_deref(), Some("test-token-123"));
1180 assert_eq!(alerts.to, "pom-alerts@makenot.work");
1181 assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
1182 assert_eq!(alerts.cooldown_secs, 120);
1183 }
1184
1185 // Incident tests
1186
1187 #[tokio::test]
1188 async fn migration_v4_creates_incidents_table() {
1189 let pool = db::connect_in_memory().await.unwrap();
1190 let version = db::get_schema_version(&pool).await.unwrap();
1191 assert_eq!(version, 15);
1192
1193 // Verify incidents table exists by inserting
1194 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
1195 .await
1196 .unwrap();
1197 assert!(id > 0);
1198 }
1199
1200 #[tokio::test]
1201 async fn incident_insert_and_close_lifecycle() {
1202 let pool = db::connect_in_memory().await.unwrap();
1203
1204 // Open an incident
1205 let id = db::insert_incident(&pool, "mnw", "operational", "degraded")
1206 .await
1207 .unwrap();
1208 assert!(id > 0);
1209
1210 // Should be visible as open
1211 let open = db::get_open_incident(&pool, "mnw").await.unwrap();
1212 assert!(open.is_some());
1213 let open = open.unwrap();
1214 assert_eq!(open.from_status, "operational");
1215 assert_eq!(open.to_status, "degraded");
1216 assert!(open.ended_at.is_none());
1217
1218 // Close it
1219 let closed_count = db::close_open_incidents(&pool, "mnw").await.unwrap();
1220 assert_eq!(closed_count, 1);
1221
1222 // No more open incidents
1223 let open = db::get_open_incident(&pool, "mnw").await.unwrap();
1224 assert!(open.is_none());
1225
1226 // Recent incidents should include the closed one
1227 let recent = db::get_recent_incidents(&pool, "mnw", 10).await.unwrap();
1228 assert_eq!(recent.len(), 1);
1229 assert!(recent[0].ended_at.is_some());
1230 assert!(recent[0].duration_secs.is_some());
1231 }
1232
1233 #[tokio::test]
1234 async fn incident_close_only_affects_target() {
1235 let pool = db::connect_in_memory().await.unwrap();
1236
1237 db::insert_incident(&pool, "mnw", "operational", "error")
1238 .await
1239 .unwrap();
1240 db::insert_incident(&pool, "other", "operational", "error")
1241 .await
1242 .unwrap();
1243
1244 // Close only mnw
1245 db::close_open_incidents(&pool, "mnw").await.unwrap();
1246
1247 assert!(db::get_open_incident(&pool, "mnw").await.unwrap().is_none());
1248 assert!(
1249 db::get_open_incident(&pool, "other")
1250 .await
1251 .unwrap()
1252 .is_some()
1253 );
1254 }
1255
1256 #[tokio::test]
1257 async fn prune_removes_closed_incidents_only() {
1258 let pool = db::connect_in_memory().await.unwrap();
1259
1260 // Insert an old closed incident
1261 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
1262 sqlx::query(
1263 "INSERT INTO incidents (target, started_at, ended_at, duration_secs, from_status, to_status)
1264 VALUES (?, ?, ?, 3600, 'operational', 'error')",
1265 )
1266 .bind("mnw")
1267 .bind(&old_time)
1268 .bind(&old_time)
1269 .execute(&pool)
1270 .await
1271 .unwrap();
1272
1273 // Insert an old open incident (should NOT be pruned)
1274 sqlx::query(
1275 "INSERT INTO incidents (target, started_at, from_status, to_status)
1276 VALUES (?, ?, 'operational', 'error')",
1277 )
1278 .bind("mnw")
1279 .bind(&old_time)
1280 .execute(&pool)
1281 .await
1282 .unwrap();
1283
1284 let result = db::prune_old_records(&pool, 30).await.unwrap();
1285 assert_eq!(result.incidents, 1); // only the closed one
1286
1287 // The open incident should remain
1288 let remaining = db::get_recent_incidents(&pool, "mnw", 10).await.unwrap();
1289 assert_eq!(remaining.len(), 1);
1290 assert!(remaining[0].ended_at.is_none());
1291 }
1292
1293 #[tokio::test]
1294 async fn api_status_includes_incidents() {
1295 let pool = db::connect_in_memory().await.unwrap();
1296 let config = test_config();
1297 let app = pom::api::router(pool.clone(), config, None);
1298
1299 // Insert an open incident
1300 db::insert_incident(&pool, "mnw", "operational", "degraded")
1301 .await
1302 .unwrap();
1303
1304 let (status, json) = api_get(&app, "/api/status/mnw").await;
1305 assert_eq!(status, 200);
1306 assert!(json["current_incident"].is_object());
1307 assert_eq!(json["current_incident"]["from_status"], "operational");
1308 assert_eq!(json["current_incident"]["to_status"], "degraded");
1309 assert!(!json["incidents"].as_array().unwrap().is_empty());
1310 }
1311
1312 #[tokio::test]
1313 async fn api_status_no_incidents_omits_fields() {
1314 let pool = db::connect_in_memory().await.unwrap();
1315 let config = test_config();
1316 let app = pom::api::router(pool, config, None);
1317
1318 let (status, json) = api_get(&app, "/api/status/mnw").await;
1319 assert_eq!(status, 200);
1320 // current_incident and incidents should be absent
1321 assert!(json.get("current_incident").is_none());
1322 assert!(json.get("incidents").is_none());
1323 }
1324
1325 // Route check tests
1326
1327 #[tokio::test]
1328 async fn migration_v5_creates_route_checks_table() {
1329 let pool = db::connect_in_memory().await.unwrap();
1330 let version = db::get_schema_version(&pool).await.unwrap();
1331 assert_eq!(version, 15);
1332
1333 // Verify route_checks table exists by inserting
1334 let result = pom::checks::routes::RouteCheckResult {
1335 target: "mnw".to_string(),
1336 path: "/".to_string(),
1337 status_code: 200,
1338 ok: true,
1339 checked_at: chrono::Utc::now().to_rfc3339(),
1340 response_time_ms: 50,
1341 error: None,
1342 };
1343 let id = db::insert_route_check(&pool, &result).await.unwrap();
1344 assert!(id > 0);
1345 }
1346
1347 #[tokio::test]
1348 async fn route_check_insert_and_latest_query() {
1349 let pool = db::connect_in_memory().await.unwrap();
1350
1351 // Insert two checks for different paths
1352 let r1 = pom::checks::routes::RouteCheckResult {
1353 target: "mnw".to_string(),
1354 path: "/".to_string(),
1355 status_code: 200,
1356 ok: true,
1357 checked_at: "2026-03-13T00:00:00Z".to_string(),
1358 response_time_ms: 50,
1359 error: None,
1360 };
1361 let r2 = pom::checks::routes::RouteCheckResult {
1362 target: "mnw".to_string(),
1363 path: "/docs".to_string(),
1364 status_code: 404,
1365 ok: false,
1366 checked_at: "2026-03-13T00:00:00Z".to_string(),
1367 response_time_ms: 30,
1368 error: Some("HTTP 404".to_string()),
1369 };
1370 db::insert_route_check(&pool, &r1).await.unwrap();
1371 db::insert_route_check(&pool, &r2).await.unwrap();
1372
1373 // Insert a newer check for "/" (should supersede the first)
1374 let r3 = pom::checks::routes::RouteCheckResult {
1375 target: "mnw".to_string(),
1376 path: "/".to_string(),
1377 status_code: 200,
1378 ok: true,
1379 checked_at: "2026-03-13T01:00:00Z".to_string(),
1380 response_time_ms: 45,
1381 error: None,
1382 };
1383 db::insert_route_check(&pool, &r3).await.unwrap();
1384
1385 let latest = db::get_latest_route_checks(&pool, "mnw").await.unwrap();
1386 assert_eq!(latest.len(), 2);
1387
1388 // "/" should have the newer check (45ms)
1389 let root = latest.iter().find(|r| r.path == "/").unwrap();
1390 assert_eq!(root.response_time_ms, 45);
1391 assert!(root.ok);
1392
1393 // "/docs" should still show 404
1394 let docs = latest.iter().find(|r| r.path == "/docs").unwrap();
1395 assert!(!docs.ok);
1396 assert_eq!(docs.status_code, 404);
1397 }
1398
1399 #[tokio::test]
1400 async fn route_check_prune() {
1401 let pool = db::connect_in_memory().await.unwrap();
1402
1403 let old = pom::checks::routes::RouteCheckResult {
1404 target: "mnw".to_string(),
1405 path: "/".to_string(),
1406 status_code: 200,
1407 ok: true,
1408 checked_at: "2020-01-01T00:00:00Z".to_string(),
1409 response_time_ms: 50,
1410 error: None,
1411 };
1412 db::insert_route_check(&pool, &old).await.unwrap();
1413
1414 let result = db::prune_old_records(&pool, 30).await.unwrap();
1415 assert_eq!(result.routes, 1);
1416 }
1417
1418 #[tokio::test]
1419 async fn api_status_includes_route_status() {
1420 let pool = db::connect_in_memory().await.unwrap();
1421 let config: pom::config::Config = toml::from_str(
1422 r#"
1423 [targets.mnw]
1424 label = "MakeNotWork"
1425 expected_routes = ["/"]
1426 [targets.mnw.health]
1427 url = "https://makenot.work/health"
1428 "#,
1429 )
1430 .unwrap();
1431 let app = pom::api::router(pool.clone(), config, None);
1432
1433 // Insert route checks
1434 let r1 = pom::checks::routes::RouteCheckResult {
1435 target: "mnw".to_string(),
1436 path: "/".to_string(),
1437 status_code: 200,
1438 ok: true,
1439 checked_at: chrono::Utc::now().to_rfc3339(),
1440 response_time_ms: 50,
1441 error: None,
1442 };
1443 db::insert_route_check(&pool, &r1).await.unwrap();
1444
1445 let (status, json) = api_get(&app, "/api/status/mnw").await;
1446 assert_eq!(status, 200);
1447 let routes = json["route_status"].as_array().unwrap();
1448 assert_eq!(routes.len(), 1);
1449 assert_eq!(routes[0]["path"], "/");
1450 assert_eq!(routes[0]["ok"], true);
1451 }
1452
1453 #[tokio::test]
1454 async fn api_status_omits_empty_route_status() {
1455 let pool = db::connect_in_memory().await.unwrap();
1456 let config = test_config();
1457 let app = pom::api::router(pool.clone(), config, None);
1458
1459 let (status, json) = api_get(&app, "/api/status/mnw").await;
1460 assert_eq!(status, 200);
1461 // route_status should be omitted when empty (skip_serializing_if)
1462 assert!(json.get("route_status").is_none());
1463 }
1464
1465 // Latency trending tests
1466
1467 #[tokio::test]
1468 async fn get_response_times_returns_ordered_data() {
1469 let pool = db::connect_in_memory().await.unwrap();
1470
1471 for i in 0..5 {
1472 let snapshot = HealthSnapshot {
1473 id: None,
1474 target: "mnw".to_string(),
1475 status: HealthStatus::Operational,
1476 checked_at: format!("2026-03-10T0{i}:00:00+00:00"),
1477 response_time_ms: 100 + i * 10,
1478 details: None,
1479 error: None,
1480 };
1481 db::insert_health_check(&pool, &snapshot).await.unwrap();
1482 }
1483
1484 let times = db::get_response_times(&pool, "mnw", "2026-03-10T00:00:00+00:00")
1485 .await
1486 .unwrap();
1487 assert_eq!(times.len(), 5);
1488 // Verify ASC ordering
1489 assert!(times[0].1 <= times[4].1);
1490 }
1491
1492 #[tokio::test]
1493 async fn get_recent_response_times_filters_operational_only() {
1494 let pool = db::connect_in_memory().await.unwrap();
1495
1496 // Insert operational checks
1497 for i in 0..3 {
1498 let snapshot = HealthSnapshot {
1499 id: None,
1500 target: "mnw".to_string(),
1501 status: HealthStatus::Operational,
1502 checked_at: format!("2026-03-10T0{i}:00:00Z"),
1503 response_time_ms: 100 + i * 10,
1504 details: None,
1505 error: None,
1506 };
1507 db::insert_health_check(&pool, &snapshot).await.unwrap();
1508 }
1509
1510 // Insert non-operational checks
1511 let error_snapshot = HealthSnapshot {
1512 id: None,
1513 target: "mnw".to_string(),
1514 status: HealthStatus::Error,
1515 checked_at: "2026-03-10T03:00:00Z".to_string(),
1516 response_time_ms: 5000,
1517 details: None,
1518 error: Some("timeout".to_string()),
1519 };
1520 db::insert_health_check(&pool, &error_snapshot)
1521 .await
1522 .unwrap();
1523
1524 let times = db::get_recent_response_times(&pool, "mnw", 10)
1525 .await
1526 .unwrap();
1527 assert_eq!(times.len(), 3); // only operational
1528 // All should be our operational values, not the 5000ms error
1529 assert!(times.iter().all(|&t| t < 5000));
1530 }
1531
1532 #[tokio::test]
1533 async fn api_trends_returns_buckets() {
1534 let pool = db::connect_in_memory().await.unwrap();
1535 let config = test_config();
1536 let app = pom::api::router(pool.clone(), config, None);
1537
1538 // Insert hourly data points
1539 for i in 0..5 {
1540 let snapshot = HealthSnapshot {
1541 id: None,
1542 target: "mnw".to_string(),
1543 status: HealthStatus::Operational,
1544 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
1545 response_time_ms: 100 + i * 20,
1546 details: None,
1547 error: None,
1548 };
1549 db::insert_health_check(&pool, &snapshot).await.unwrap();
1550 }
1551
1552 let (status, json) = api_get(&app, "/api/trends/mnw?hours=24&bucket_minutes=60").await;
1553 assert_eq!(status, 200);
1554 assert_eq!(json["target"], "mnw");
1555 assert_eq!(json["window_hours"], 24);
1556 assert_eq!(json["bucket_minutes"], 60);
1557 assert!(!json["buckets"].as_array().unwrap().is_empty());
1558 assert!(json["overall"].is_object());
1559 }
1560
1561 #[tokio::test]
1562 async fn api_trends_nonexistent_target() {
1563 let pool = db::connect_in_memory().await.unwrap();
1564 let config = test_config();
1565 let app = pom::api::router(pool, config, None);
1566
1567 let (status, json) = api_get(&app, "/api/trends/nonexistent").await;
1568 assert_eq!(status, 404);
1569 assert!(json["error"].as_str().unwrap().contains("unknown target"));
1570 }
1571
1572 #[tokio::test]
1573 async fn api_status_includes_latency_24h_with_data() {
1574 let pool = db::connect_in_memory().await.unwrap();
1575 let config = test_config();
1576 let app = pom::api::router(pool.clone(), config, None);
1577
1578 // Insert recent operational check
1579 let snapshot = HealthSnapshot {
1580 id: None,
1581 target: "mnw".to_string(),
1582 status: HealthStatus::Operational,
1583 checked_at: chrono::Utc::now().to_rfc3339(),
1584 response_time_ms: 120,
1585 details: None,
1586 error: None,
1587 };
1588 db::insert_health_check(&pool, &snapshot).await.unwrap();
1589
1590 let (status, json) = api_get(&app, "/api/status/mnw").await;
1591 assert_eq!(status, 200);
1592 assert!(json["latency_24h"].is_object());
1593 assert_eq!(json["latency_24h"]["min_ms"], 120);
1594 assert_eq!(json["latency_24h"]["sample_count"], 1);
1595 }
1596
1597 #[tokio::test]
1598 async fn api_status_omits_latency_24h_when_no_data() {
1599 let pool = db::connect_in_memory().await.unwrap();
1600 let config = test_config();
1601 let app = pom::api::router(pool, config, None);
1602
1603 let (status, json) = api_get(&app, "/api/status/mnw").await;
1604 assert_eq!(status, 200);
1605 // latency_24h should be absent (skip_serializing_if)
1606 assert!(json.get("latency_24h").is_none());
1607 }
1608
1609 #[tokio::test]
1610 async fn config_trending_parses() {
1611 let toml = r#"
1612 [targets.mnw]
1613 label = "MakeNotWork"
1614 [targets.mnw.health]
1615 url = "https://makenot.work/health"
1616 [targets.mnw.health.trending]
1617 baseline_window_hours = 48
1618 spike_threshold = 1.5
1619 "#;
1620 let config: pom::config::Config = toml::from_str(toml).unwrap();
1621 let trending = config
1622 .get_target("mnw")
1623 .unwrap()
1624 .health
1625 .as_ref()
1626 .unwrap()
1627 .trending
1628 .as_ref()
1629 .unwrap();
1630 assert_eq!(trending.baseline_window_hours, 48);
1631 assert!((trending.spike_threshold - 1.5).abs() < f64::EPSILON);
1632 }
1633
1634 #[tokio::test]
1635 async fn config_with_health_expect_parses() {
1636 let toml = r#"
1637 [targets.mnw]
1638 label = "MakeNotWork"
1639 [targets.mnw.health]
1640 url = "https://makenot.work/health"
1641 [targets.mnw.health.expect]
1642 status_code = 200
1643 json_fields = { "status" = "operational" }
1644 "#;
1645 let config: pom::config::Config = toml::from_str(toml).unwrap();
1646 let expect = config
1647 .get_target("mnw")
1648 .unwrap()
1649 .health
1650 .as_ref()
1651 .unwrap()
1652 .expect
1653 .as_ref()
1654 .unwrap();
1655 assert_eq!(expect.status_code, Some(200));
1656 assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
1657 }
1658
1659 // Test staleness tests
1660
1661 fn test_config_with_tests() -> pom::config::Config {
1662 toml::from_str(
1663 r#"
1664 [targets.mnw]
1665 label = "MakeNotWork"
1666 [targets.mnw.health]
1667 url = "https://makenot.work/health"
1668 [targets.mnw.tests]
1669 ssh = "max@host"
1670 command = "./ci.sh"
1671 staleness_days = 7
1672 "#,
1673 )
1674 .unwrap()
1675 }
1676
1677 #[tokio::test]
1678 async fn get_version_at_time_returns_version() {
1679 let pool = db::connect_in_memory().await.unwrap();
1680
1681 // Insert a health check with version details
1682 let snapshot = HealthSnapshot {
1683 id: None,
1684 target: "mnw".to_string(),
1685 status: HealthStatus::Operational,
1686 checked_at: "2026-03-10T00:00:00Z".to_string(),
1687 response_time_ms: 95,
1688 details: Some(HealthDetails {
1689 version: Some("0.1.8".to_string()),
1690 git_sha: None,
1691 uptime: None,
1692 checks: None,
1693 monitoring: None,
1694 }),
1695 error: None,
1696 };
1697 db::insert_health_check(&pool, &snapshot).await.unwrap();
1698
1699 let version = db::get_version_at_time(&pool, "mnw", "2026-03-10T01:00:00Z")
1700 .await
1701 .unwrap();
1702 assert_eq!(version, Some("0.1.8".to_string()));
1703 }
1704
1705 #[tokio::test]
1706 async fn get_version_at_time_returns_none_when_no_data() {
1707 let pool = db::connect_in_memory().await.unwrap();
1708
1709 let version = db::get_version_at_time(&pool, "mnw", "2026-03-10T01:00:00Z")
1710 .await
1711 .unwrap();
1712 assert!(version.is_none());
1713 }
1714
1715 #[tokio::test]
1716 async fn api_status_includes_staleness_version_change() {
1717 let pool = db::connect_in_memory().await.unwrap();
1718 let config = test_config_with_tests();
1719 let app = pom::api::router(pool.clone(), config, None);
1720
1721 // Insert health check with version 0.1.8 before test run
1722 let old_health = HealthSnapshot {
1723 id: None,
1724 target: "mnw".to_string(),
1725 status: HealthStatus::Operational,
1726 checked_at: "2026-03-09T00:00:00Z".to_string(),
1727 response_time_ms: 95,
1728 details: Some(HealthDetails {
1729 version: Some("0.1.8".to_string()),
1730 git_sha: None,
1731 uptime: None,
1732 checks: None,
1733 monitoring: None,
1734 }),
1735 error: None,
1736 };
1737 db::insert_health_check(&pool, &old_health).await.unwrap();
1738
1739 // Insert test run at a time when version was 0.1.8
1740 let run = TestRun {
1741 id: None,
1742 target: "mnw".to_string(),
1743 started_at: chrono::Utc::now().to_rfc3339(),
1744 finished_at: None,
1745 duration_secs: Some(60),
1746 exit_code: Some(0),
1747 passed: true,
1748 summary: TestSummary {
1749 steps: vec![],
1750 total_passed: Some(100),
1751 total_failed: Some(0),
1752 details: vec![],
1753 },
1754 raw_output: String::new(),
1755 filter: None,
1756 };
1757 db::insert_test_run(&pool, &run).await.unwrap();
1758
1759 // Insert current health check with version 0.1.9
1760 let new_health = HealthSnapshot {
1761 id: None,
1762 target: "mnw".to_string(),
1763 status: HealthStatus::Operational,
1764 checked_at: chrono::Utc::now().to_rfc3339(),
1765 response_time_ms: 95,
1766 details: Some(HealthDetails {
1767 version: Some("0.1.9".to_string()),
1768 git_sha: None,
1769 uptime: None,
1770 checks: None,
1771 monitoring: None,
1772 }),
1773 error: None,
1774 };
1775 db::insert_health_check(&pool, &new_health).await.unwrap();
1776
1777 let (status, json) = api_get(&app, "/api/status/mnw").await;
1778 assert_eq!(status, 200);
1779 assert!(json["test_staleness"].is_object());
1780 assert_eq!(json["test_staleness"]["stale"], true);
1781 let reason = json["test_staleness"]["reason"].as_str().unwrap();
1782 assert!(reason.contains("version changed"), "reason was: {reason}");
1783 }
1784
1785 #[tokio::test]
1786 async fn api_status_includes_staleness_by_age() {
1787 let pool = db::connect_in_memory().await.unwrap();
1788 let config = test_config_with_tests();
1789 let app = pom::api::router(pool.clone(), config, None);
1790
1791 // Insert old health check
1792 let old_time = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
1793 let health = HealthSnapshot {
1794 id: None,
1795 target: "mnw".to_string(),
1796 status: HealthStatus::Operational,
1797 checked_at: old_time.clone(),
1798 response_time_ms: 95,
1799 details: Some(HealthDetails {
1800 version: Some("0.1.9".to_string()),
1801 git_sha: None,
1802 uptime: None,
1803 checks: None,
1804 monitoring: None,
1805 }),
1806 error: None,
1807 };
1808 db::insert_health_check(&pool, &health).await.unwrap();
1809
1810 // Insert old test run (10 days ago, same version)
1811 let run = TestRun {
1812 id: None,
1813 target: "mnw".to_string(),
1814 started_at: old_time,
1815 finished_at: None,
1816 duration_secs: Some(60),
1817 exit_code: Some(0),
1818 passed: true,
1819 summary: TestSummary {
1820 steps: vec![],
1821 total_passed: Some(100),
1822 total_failed: Some(0),
1823 details: vec![],
1824 },
1825 raw_output: String::new(),
1826 filter: None,
1827 };
1828 db::insert_test_run(&pool, &run).await.unwrap();
1829
1830 // Insert current health (same version)
1831 let current_health = HealthSnapshot {
1832 id: None,
1833 target: "mnw".to_string(),
1834 status: HealthStatus::Operational,
1835 checked_at: chrono::Utc::now().to_rfc3339(),
1836 response_time_ms: 95,
1837 details: Some(HealthDetails {
1838 version: Some("0.1.9".to_string()),
1839 git_sha: None,
1840 uptime: None,
1841 checks: None,
1842 monitoring: None,
1843 }),
1844 error: None,
1845 };
1846 db::insert_health_check(&pool, &current_health)
1847 .await
1848 .unwrap();
1849
1850 let (status, json) = api_get(&app, "/api/status/mnw").await;
1851 assert_eq!(status, 200);
1852 assert!(json["test_staleness"].is_object());
1853 assert_eq!(json["test_staleness"]["stale"], true);
1854 let reason = json["test_staleness"]["reason"].as_str().unwrap();
1855 assert!(reason.contains("days old"), "reason was: {reason}");
1856 }
1857
1858 #[tokio::test]
1859 async fn api_status_not_stale_when_fresh() {
1860 let pool = db::connect_in_memory().await.unwrap();
1861 let config = test_config_with_tests();
1862 let app = pom::api::router(pool.clone(), config, None);
1863
1864 // Insert health check with version
1865 let health = HealthSnapshot {
1866 id: None,
1867 target: "mnw".to_string(),
1868 status: HealthStatus::Operational,
1869 checked_at: chrono::Utc::now().to_rfc3339(),
1870 response_time_ms: 95,
1871 details: Some(HealthDetails {
1872 version: Some("0.1.9".to_string()),
1873 git_sha: None,
1874 uptime: None,
1875 checks: None,
1876 monitoring: None,
1877 }),
1878 error: None,
1879 };
1880 db::insert_health_check(&pool, &health).await.unwrap();
1881
1882 // Insert recent test run
1883 let run = TestRun {
1884 id: None,
1885 target: "mnw".to_string(),
1886 started_at: chrono::Utc::now().to_rfc3339(),
1887 finished_at: None,
1888 duration_secs: Some(60),
1889 exit_code: Some(0),
1890 passed: true,
1891 summary: TestSummary {
1892 steps: vec![],
1893 total_passed: Some(100),
1894 total_failed: Some(0),
1895 details: vec![],
1896 },
1897 raw_output: String::new(),
1898 filter: None,
1899 };
1900 db::insert_test_run(&pool, &run).await.unwrap();
1901
1902 let (status, json) = api_get(&app, "/api/status/mnw").await;
1903 assert_eq!(status, 200);
1904 assert!(json["test_staleness"].is_object());
1905 assert_eq!(json["test_staleness"]["stale"], false);
1906 }
1907
1908 #[tokio::test]
1909 async fn config_staleness_days_parses() {
1910 let toml = r#"
1911 [targets.mnw]
1912 label = "MakeNotWork"
1913 [targets.mnw.tests]
1914 ssh = "host"
1915 command = "./ci.sh"
1916 staleness_days = 14
1917 "#;
1918 let config: pom::config::Config = toml::from_str(toml).unwrap();
1919 assert_eq!(
1920 config
1921 .get_target("mnw")
1922 .unwrap()
1923 .tests
1924 .as_ref()
1925 .unwrap()
1926 .staleness_days,
1927 14
1928 );
1929 }
1930
1931 #[tokio::test]
1932 async fn tool_get_status_shows_staleness() {
1933 let pool = db::connect_in_memory().await.unwrap();
1934 let config = test_config_with_tests();
1935 let server = PomServer::new(pool.clone(), config);
1936
1937 // No test data, should show stale
1938 let result = server.get_status_impl().await.unwrap();
1939 assert!(result.contains("STALE"), "output was: {result}");
1940 assert!(
1941 result.contains("no tests have been run"),
1942 "output was: {result}"
1943 );
1944 }
1945
1946 #[tokio::test]
1947 async fn api_status_no_staleness_without_tests_config() {
1948 let pool = db::connect_in_memory().await.unwrap();
1949 let config = test_config(); // no tests section
1950 let app = pom::api::router(pool, config, None);
1951
1952 let (status, json) = api_get(&app, "/api/status/mnw").await;
1953 assert_eq!(status, 200);
1954 // test_staleness should be absent when no tests config
1955 assert!(json.get("test_staleness").is_none());
1956 }
1957
1958 // Prune days=0 guard tests
1959
1960 #[tokio::test]
1961 async fn prune_with_days_zero_is_noop() {
1962 let pool = db::connect_in_memory().await.unwrap();
1963
1964 // Insert a recent health check
1965 let snapshot = HealthSnapshot {
1966 id: None,
1967 target: "mnw".to_string(),
1968 status: HealthStatus::Operational,
1969 checked_at: chrono::Utc::now().to_rfc3339(),
1970 response_time_ms: 100,
1971 details: None,
1972 error: None,
1973 };
1974 db::insert_health_check(&pool, &snapshot).await.unwrap();
1975
1976 // Prune with days=0 should delete nothing
1977 let result = db::prune_old_records(&pool, 0).await.unwrap();
1978 assert_eq!(result.health, 0);
1979 assert_eq!(result.tests, 0);
1980 assert_eq!(result.heartbeats, 0);
1981 assert_eq!(result.alerts, 0);
1982 assert_eq!(result.tls, 0);
1983 assert_eq!(result.incidents, 0);
1984 assert_eq!(result.routes, 0);
1985 assert_eq!(result.dns, 0);
1986 assert_eq!(result.whois, 0);
1987
1988 // Records should still exist
1989 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
1990 assert_eq!(remaining.len(), 1);
1991 }
1992
1993 #[tokio::test]
1994 async fn prune_with_days_seven_keeps_recent() {
1995 let pool = db::connect_in_memory().await.unwrap();
1996
1997 // Insert a health check from yesterday
1998 let yesterday = HealthSnapshot {
1999 id: None,
2000 target: "mnw".to_string(),
2001 status: HealthStatus::Operational,
2002 checked_at: (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339(),
2003 response_time_ms: 100,
2004 details: None,
2005 error: None,
2006 };
2007 db::insert_health_check(&pool, &yesterday).await.unwrap();
2008
2009 // Insert a health check from 10 days ago
2010 let old = HealthSnapshot {
2011 id: None,
2012 target: "mnw".to_string(),
2013 status: HealthStatus::Operational,
2014 checked_at: (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(),
2015 response_time_ms: 200,
2016 details: None,
2017 error: None,
2018 };
2019 db::insert_health_check(&pool, &old).await.unwrap();
2020
2021 // Prune with days=7 should only delete the 10-day-old record
2022 let result = db::prune_old_records(&pool, 7).await.unwrap();
2023 assert_eq!(result.health, 1);
2024
2025 // Yesterday's record should remain
2026 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
2027 assert_eq!(remaining.len(), 1);
2028 assert_eq!(remaining[0].response_time_ms, 100);
2029 }
2030
2031 #[tokio::test]
2032 async fn prune_with_days_one_keeps_today() {
2033 let pool = db::connect_in_memory().await.unwrap();
2034
2035 // Insert a health check from now
2036 let today = HealthSnapshot {
2037 id: None,
2038 target: "mnw".to_string(),
2039 status: HealthStatus::Operational,
2040 checked_at: chrono::Utc::now().to_rfc3339(),
2041 response_time_ms: 100,
2042 details: None,
2043 error: None,
2044 };
2045 db::insert_health_check(&pool, &today).await.unwrap();
2046
2047 // Insert a health check from 2 days ago
2048 let old = HealthSnapshot {
2049 id: None,
2050 target: "mnw".to_string(),
2051 status: HealthStatus::Operational,
2052 checked_at: (chrono::Utc::now() - chrono::Duration::days(2)).to_rfc3339(),
2053 response_time_ms: 200,
2054 details: None,
2055 error: None,
2056 };
2057 db::insert_health_check(&pool, &old).await.unwrap();
2058
2059 // Prune with days=1 should delete the 2-day-old record, keep today's
2060 let result = db::prune_old_records(&pool, 1).await.unwrap();
2061 assert_eq!(result.health, 1);
2062
2063 let remaining = db::get_health_history(&pool, None, 10).await.unwrap();
2064 assert_eq!(remaining.len(), 1);
2065 assert_eq!(remaining[0].response_time_ms, 100);
2066 }
2067
2068 #[tokio::test]
2069 async fn prune_counts_cascade_deleted_test_details() {
2070 let pool = db::connect_in_memory().await.unwrap();
2071
2072 let run = TestRun {
2073 id: None,
2074 target: "mnw".to_string(),
2075 started_at: (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(),
2076 finished_at: None,
2077 duration_secs: Some(120),
2078 exit_code: Some(0),
2079 passed: true,
2080 summary: TestSummary {
2081 steps: vec![],
2082 total_passed: Some(3),
2083 total_failed: Some(0),
2084 details: vec![
2085 TestDetail {
2086 test_name: "foo::bar".to_string(),
2087 passed: true,
2088 },
2089 TestDetail {
2090 test_name: "foo::baz".to_string(),
2091 passed: true,
2092 },
2093 TestDetail {
2094 test_name: "foo::qux".to_string(),
2095 passed: true,
2096 },
2097 ],
2098 },
2099 raw_output: String::new(),
2100 filter: None,
2101 };
2102 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
2103 db::insert_test_details(&pool, run_id, &run.summary.details)
2104 .await
2105 .unwrap();
2106
2107 // The ON DELETE CASCADE removes the details along with the run; the count
2108 // must still reflect them rather than reading 0 off an empty orphan sweep.
2109 let result = db::prune_old_records(&pool, 7).await.unwrap();
2110 assert_eq!(result.tests, 1);
2111 assert_eq!(result.test_details, 3);
2112 }
2113
2114 // SSH timeout_secs config test
2115
2116 #[test]
2117 fn ssh_config_timeout_secs_is_parsed() {
2118 let toml = r#"
2119 [targets.mnw]
2120 label = "MakeNotWork"
2121 [targets.mnw.tests]
2122 ssh = "hetzner"
2123 command = "./ci.sh"
2124 timeout_secs = 5
2125 "#;
2126 let config: pom::config::Config = toml::from_str(toml).unwrap();
2127 let tests = config.get_target("mnw").unwrap().tests.as_ref().unwrap();
2128 assert_eq!(tests.timeout_secs, 5);
2129 }
2130
2131 #[test]
2132 fn ssh_config_timeout_secs_default() {
2133 let toml = r#"
2134 [targets.mnw]
2135 label = "MakeNotWork"
2136 [targets.mnw.tests]
2137 ssh = "hetzner"
2138 command = "./ci.sh"
2139 "#;
2140 let config: pom::config::Config = toml::from_str(toml).unwrap();
2141 let tests = config.get_target("mnw").unwrap().tests.as_ref().unwrap();
2142 assert_eq!(tests.timeout_secs, 600); // default
2143 }
2144
2145 // Alert cooldown key consistency test
2146
2147 #[tokio::test]
2148 async fn alert_cooldown_key_matches_across_send_and_check() {
2149 let pool = db::connect_in_memory().await.unwrap();
2150
2151 let config = pom::config::AlertConfig {
2152 postmark_token: None,
2153 to: "test@example.com".to_string(),
2154 from: "PoM Alerts <pom@test.com>".to_string(),
2155 cooldown_secs: 300,
2156 wam_url: None,
2157 wam_token: None,
2158 mnw_url: None,
2159 alerts_ingest_token: None,
2160 };
2161 let alerter = pom::alerts::Alerter::new(config, pool.clone(), "test".to_string()).unwrap();
2162
2163 // Send a health alert for target "example.com"
2164 alerter
2165 .send_health_alert("example.com", "Example", "operational", "error", None)
2166 .await;
2167
2168 // The alert should be recorded with key "health:example.com"
2169 let alert = db::get_latest_alert_for_target(&pool, "health:example.com")
2170 .await
2171 .unwrap();
2172 assert!(
2173 alert.is_some(),
2174 "alert should be recorded with prefixed key"
2175 );
2176
2177 // The old (bare) key should have no record
2178 let bare = db::get_latest_alert_for_target(&pool, "example.com")
2179 .await
2180 .unwrap();
2181 assert!(
2182 bare.is_none(),
2183 "no alert should exist under bare target name"
2184 );
2185 }
2186
2187 // check_health integration tests (mock HTTP server)
2188
2189 #[tokio::test]
2190 async fn check_health_operational_json_response() {
2191 use axum::routing::get;
2192 use pom::checks::http::check_health;
2193 use pom::config::HealthConfig;
2194
2195 let app = axum::Router::new().route(
2196 "/health",
2197 get(|| async {
2198 axum::Json(serde_json::json!({
2199 "status": "operational",
2200 "version": "1.0.0",
2201 "uptime": "2d 5h",
2202 }))
2203 }),
2204 );
2205 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2206 let addr = listener.local_addr().unwrap();
2207 tokio::spawn(async move {
2208 axum::serve(listener, app).await.unwrap();
2209 });
2210
2211 let config = HealthConfig {
2212 url: format!("http://{addr}/health"),
2213 timeout_secs: 5,
2214 interval_secs: None,
2215 expect: None,
2216 trending: None,
2217 };
2218 let snapshot = check_health("test", &config, None).await;
2219 assert_eq!(snapshot.status, HealthStatus::Operational);
2220 assert!(snapshot.response_time_ms >= 0);
2221 let details = snapshot.details.unwrap();
2222 assert_eq!(details.version.as_deref(), Some("1.0.0"));
2223 assert_eq!(details.uptime.as_deref(), Some("2d 5h"));
2224 assert!(snapshot.error.is_none());
2225 }
2226
2227 #[tokio::test]
2228 async fn check_health_degraded_unknown_status() {
2229 use axum::routing::get;
2230 use pom::checks::http::check_health;
2231 use pom::config::HealthConfig;
2232
2233 let app = axum::Router::new().route(
2234 "/health",
2235 get(|| async {
2236 axum::Json(serde_json::json!({
2237 "status": "starting_up",
2238 "version": "1.0.0",
2239 }))
2240 }),
2241 );
2242 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2243 let addr = listener.local_addr().unwrap();
2244 tokio::spawn(async move {
2245 axum::serve(listener, app).await.unwrap();
2246 });
2247
2248 let config = HealthConfig {
2249 url: format!("http://{addr}/health"),
2250 timeout_secs: 5,
2251 interval_secs: None,
2252 expect: None,
2253 trending: None,
2254 };
2255 let snapshot = check_health("test", &config, None).await;
2256 assert_eq!(snapshot.status, HealthStatus::Degraded);
2257 }
2258
2259 #[tokio::test]
2260 async fn check_health_unreachable_target() {
2261 use pom::checks::http::check_health;
2262 use pom::config::HealthConfig;
2263
2264 let config = HealthConfig {
2265 url: "http://127.0.0.1:19999/health".to_string(),
2266 timeout_secs: 1,
2267 interval_secs: None,
2268 expect: None,
2269 trending: None,
2270 };
2271 let snapshot = check_health("test", &config, None).await;
2272 assert_eq!(snapshot.status, HealthStatus::Unreachable);
2273 assert!(snapshot.error.is_some());
2274 }
2275
2276 #[tokio::test]
2277 async fn check_health_with_expectations_passing() {
2278 use axum::routing::get;
2279 use pom::checks::http::check_health;
2280 use pom::config::{HealthConfig, HealthExpectation};
2281
2282 let app = axum::Router::new().route(
2283 "/health",
2284 get(|| async {
2285 axum::Json(serde_json::json!({
2286 "status": "operational",
2287 "version": "1.0.0",
2288 }))
2289 }),
2290 );
2291 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2292 let addr = listener.local_addr().unwrap();
2293 tokio::spawn(async move {
2294 axum::serve(listener, app).await.unwrap();
2295 });
2296
2297 let expect = HealthExpectation {
2298 status_code: Some(200),
2299 json_fields: [("status".to_string(), "operational".to_string())].into(),
2300 body_contains: None,
2301 };
2302 let config = HealthConfig {
2303 url: format!("http://{addr}/health"),
2304 timeout_secs: 5,
2305 interval_secs: None,
2306 expect: Some(expect.clone()),
2307 trending: None,
2308 };
2309 let snapshot = check_health("test", &config, Some(&expect)).await;
2310 assert_eq!(snapshot.status, HealthStatus::Operational);
2311 assert!(snapshot.error.is_none());
2312 }
2313
2314 #[tokio::test]
2315 async fn check_health_with_expectations_failing() {
2316 use axum::routing::get;
2317 use pom::checks::http::check_health;
2318 use pom::config::{HealthConfig, HealthExpectation};
2319
2320 let app = axum::Router::new().route(
2321 "/health",
2322 get(|| async {
2323 axum::Json(serde_json::json!({
2324 "status": "degraded",
2325 "version": "1.0.0",
2326 }))
2327 }),
2328 );
2329 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2330 let addr = listener.local_addr().unwrap();
2331 tokio::spawn(async move {
2332 axum::serve(listener, app).await.unwrap();
2333 });
2334
2335 let expect = HealthExpectation {
2336 status_code: Some(200),
2337 json_fields: [("status".to_string(), "operational".to_string())].into(),
2338 body_contains: None,
2339 };
2340 let config = HealthConfig {
2341 url: format!("http://{addr}/health"),
2342 timeout_secs: 5,
2343 interval_secs: None,
2344 expect: Some(expect.clone()),
2345 trending: None,
2346 };
2347 let snapshot = check_health("test", &config, Some(&expect)).await;
2348 assert_eq!(snapshot.status, HealthStatus::Degraded);
2349 assert!(snapshot.error.is_some());
2350 assert!(snapshot.error.unwrap().contains("expected \"operational\""));
2351 }
2352
2353 // check_tls integration test (self-signed cert)
2354
2355 #[tokio::test]
2356 async fn check_tls_with_test_cert() {
2357 use pom::checks::tls::check_tls;
2358 use pom::config::TlsConfig;
2359 use rcgen::generate_simple_self_signed;
2360 use tokio_rustls::rustls;
2361
2362 // Install crypto provider (tests don't go through main()).
2363 pom::tls::install_crypto_provider();
2364
2365 // Generate a self-signed cert
2366 let subject_alt_names = vec!["localhost".to_string()];
2367 let cert = generate_simple_self_signed(subject_alt_names).unwrap();
2368 let cert_der = cert.cert.der().clone();
2369 let key_der = cert.signing_key.serialize_der();
2370
2371 // Start a TLS server
2372 let server_config = rustls::ServerConfig::builder()
2373 .with_no_client_auth()
2374 .with_single_cert(
2375 vec![rustls_pki_types::CertificateDer::from(cert_der.to_vec())],
2376 rustls_pki_types::PrivateKeyDer::try_from(key_der).unwrap(),
2377 )
2378 .unwrap();
2379
2380 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(server_config));
2381 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2382 let port = listener.local_addr().unwrap().port();
2383
2384 tokio::spawn(async move {
2385 // Accept one connection to prove TLS works
2386 if let Ok((stream, _)) = listener.accept().await {
2387 let _ = acceptor.accept(stream).await;
2388 }
2389 });
2390
2391 let tls_config = TlsConfig {
2392 host: "localhost".to_string(),
2393 port,
2394 warn_days: 14,
2395 };
2396 let result = check_tls("test", &tls_config).await;
2397
2398 // Self-signed cert won't pass webpki validation, so this should return an error
2399 // but it should still complete without panic
2400 assert_eq!(result.target, "test");
2401 assert!(!result.checked_at.is_empty());
2402 // Self-signed cert → error expected (webpki root store doesn't include it)
2403 assert!(result.error.is_some() || result.valid);
2404 }
2405
2406 // API health endpoint test
2407
2408 #[tokio::test]
2409 async fn api_health_endpoint_returns_operational() {
2410 let pool = db::connect_in_memory().await.unwrap();
2411 let config = test_config();
2412 let app = pom::api::router(pool, config, None);
2413
2414 let (status, json) = api_get(&app, "/api/health").await;
2415 assert_eq!(status, 200);
2416 assert_eq!(json["status"], "operational");
2417 assert!(json["version"].as_str().is_some());
2418 }
2419
2420 #[tokio::test]
2421 async fn api_health_endpoint_no_auth_required() {
2422 let pool = db::connect_in_memory().await.unwrap();
2423 // Config with auth token, health endpoint should still be accessible
2424 let mut config = test_config();
2425 config.serve.api_token = Some("secret123".to_string());
2426 let app = pom::api::router(pool, config, None);
2427
2428 // No auth header, but /api/health should still work
2429 let (status, json) = api_get(&app, "/api/health").await;
2430 assert_eq!(status, 200);
2431 assert_eq!(json["status"], "operational");
2432 }
2433
2434 // Rate limit test
2435
2436 #[tokio::test]
2437 async fn api_rate_limit_rejects_excess_requests() {
2438 use pom::api::PerIpRateLimiter;
2439
2440 let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1));
2441 let limiter = PerIpRateLimiter::new(3, std::time::Duration::from_mins(1));
2442
2443 assert!(limiter.try_acquire(ip)); // 1
2444 assert!(limiter.try_acquire(ip)); // 2
2445 assert!(limiter.try_acquire(ip)); // 3
2446 assert!(!limiter.try_acquire(ip)); // 4, should be rejected
2447
2448 // A different client keeps its own budget (SERIOUS #5 isolation).
2449 let other = std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 2));
2450 assert!(limiter.try_acquire(other));
2451 }
2452
2453 // Peer UUID mismatch state reset test
2454
2455 #[tokio::test]
2456 async fn peer_uuid_mismatch_updates_db_identity() {
2457 let pool = db::connect_in_memory().await.unwrap();
2458
2459 // Store initial identity
2460 db::store_peer_identity(&pool, "peer1", "old-uuid")
2461 .await
2462 .unwrap();
2463 let stored = db::get_peer_identity(&pool, "peer1").await.unwrap();
2464 assert_eq!(stored, Some("old-uuid".to_string()));
2465
2466 // Update identity (simulating what happens on UUID mismatch)
2467 db::update_peer_identity(&pool, "peer1", "new-uuid")
2468 .await
2469 .unwrap();
2470 let stored = db::get_peer_identity(&pool, "peer1").await.unwrap();
2471 assert_eq!(stored, Some("new-uuid".to_string()));
2472 }
2473
2474 // DNS check tests
2475
2476 #[tokio::test]
2477 async fn migration_v6_creates_dns_and_whois_tables() {
2478 let pool = db::connect_in_memory().await.unwrap();
2479 let version = db::get_schema_version(&pool).await.unwrap();
2480 assert_eq!(version, 15);
2481
2482 // Verify dns_checks table exists
2483 let dns_result = DnsCheckResult {
2484 target: "mnw".to_string(),
2485 name: "makenot.work".to_string(),
2486 record_type: pom::types::DnsRecordType::A,
2487 expected: vec!["5.78.144.244".to_string()],
2488 actual: vec!["5.78.144.244".to_string()],
2489 matches: true,
2490 checked_at: chrono::Utc::now().to_rfc3339(),
2491 error: None,
2492 };
2493 let id = db::insert_dns_check(&pool, &dns_result).await.unwrap();
2494 assert!(id > 0);
2495
2496 // Verify whois_checks table exists
2497 let whois_result = WhoisResult {
2498 target: "mnw".to_string(),
2499 domain: "makenot.work".to_string(),
2500 registrar: Some("Namecheap, Inc.".to_string()),
2501 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2502 days_remaining: Some(261),
2503 nameservers: vec!["ns1.example.com".to_string()],
2504 checked_at: chrono::Utc::now().to_rfc3339(),
2505 error: None,
2506 };
2507 let id = db::insert_whois_check(&pool, &whois_result).await.unwrap();
2508 assert!(id > 0);
2509 }
2510
2511 #[tokio::test]
2512 async fn dns_check_insert_and_query() {
2513 let pool = db::connect_in_memory().await.unwrap();
2514
2515 let result = DnsCheckResult {
2516 target: "mnw".to_string(),
2517 name: "makenot.work".to_string(),
2518 record_type: pom::types::DnsRecordType::A,
2519 expected: vec!["5.78.144.244".to_string()],
2520 actual: vec!["5.78.144.244".to_string()],
2521 matches: true,
2522 checked_at: "2026-03-15T00:00:00Z".to_string(),
2523 error: None,
2524 };
2525 db::insert_dns_check(&pool, &result).await.unwrap();
2526
2527 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2528 assert_eq!(latest.len(), 1);
2529 assert_eq!(latest[0].name, "makenot.work");
2530 assert_eq!(latest[0].record_type, "A");
2531 assert!(latest[0].matches);
2532 }
2533
2534 #[tokio::test]
2535 async fn dns_check_latest_per_name_and_type() {
2536 let pool = db::connect_in_memory().await.unwrap();
2537
2538 // Insert two checks for same name/type, different times
2539 let r1 = DnsCheckResult {
2540 target: "mnw".to_string(),
2541 name: "makenot.work".to_string(),
2542 record_type: pom::types::DnsRecordType::A,
2543 expected: vec!["1.2.3.4".to_string()],
2544 actual: vec!["5.6.7.8".to_string()],
2545 matches: false,
2546 checked_at: "2026-03-15T00:00:00Z".to_string(),
2547 error: None,
2548 };
2549 let r2 = DnsCheckResult {
2550 target: "mnw".to_string(),
2551 name: "makenot.work".to_string(),
2552 record_type: pom::types::DnsRecordType::A,
2553 expected: vec!["5.78.144.244".to_string()],
2554 actual: vec!["5.78.144.244".to_string()],
2555 matches: true,
2556 checked_at: "2026-03-15T01:00:00Z".to_string(),
2557 error: None,
2558 };
2559 db::insert_dns_check(&pool, &r1).await.unwrap();
2560 db::insert_dns_check(&pool, &r2).await.unwrap();
2561
2562 // Should return only the latest check per name+type
2563 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2564 assert_eq!(latest.len(), 1);
2565 assert!(latest[0].matches);
2566 }
2567
2568 #[tokio::test]
2569 async fn dns_check_multiple_records() {
2570 let pool = db::connect_in_memory().await.unwrap();
2571
2572 let r1 = DnsCheckResult {
2573 target: "mnw".to_string(),
2574 name: "makenot.work".to_string(),
2575 record_type: pom::types::DnsRecordType::A,
2576 expected: vec!["5.78.144.244".to_string()],
2577 actual: vec!["5.78.144.244".to_string()],
2578 matches: true,
2579 checked_at: "2026-03-15T00:00:00Z".to_string(),
2580 error: None,
2581 };
2582 let r2 = DnsCheckResult {
2583 target: "mnw".to_string(),
2584 name: "forums.makenot.work".to_string(),
2585 record_type: pom::types::DnsRecordType::A,
2586 expected: vec!["5.78.144.244".to_string()],
2587 actual: vec!["5.78.144.244".to_string()],
2588 matches: true,
2589 checked_at: "2026-03-15T00:00:00Z".to_string(),
2590 error: None,
2591 };
2592 db::insert_dns_check(&pool, &r1).await.unwrap();
2593 db::insert_dns_check(&pool, &r2).await.unwrap();
2594
2595 let latest = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2596 assert_eq!(latest.len(), 2);
2597 }
2598
2599 #[tokio::test]
2600 async fn dns_check_filters_by_target() {
2601 let pool = db::connect_in_memory().await.unwrap();
2602
2603 let r1 = DnsCheckResult {
2604 target: "mnw".to_string(),
2605 name: "makenot.work".to_string(),
2606 record_type: pom::types::DnsRecordType::A,
2607 expected: vec!["5.78.144.244".to_string()],
2608 actual: vec!["5.78.144.244".to_string()],
2609 matches: true,
2610 checked_at: chrono::Utc::now().to_rfc3339(),
2611 error: None,
2612 };
2613 let r2 = DnsCheckResult {
2614 target: "htpy".to_string(),
2615 name: "htpy.app".to_string(),
2616 record_type: pom::types::DnsRecordType::A,
2617 expected: vec!["5.78.135.189".to_string()],
2618 actual: vec!["5.78.135.189".to_string()],
2619 matches: true,
2620 checked_at: chrono::Utc::now().to_rfc3339(),
2621 error: None,
2622 };
2623 db::insert_dns_check(&pool, &r1).await.unwrap();
2624 db::insert_dns_check(&pool, &r2).await.unwrap();
2625
2626 let mnw_checks = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2627 assert_eq!(mnw_checks.len(), 1);
2628 assert_eq!(mnw_checks[0].name, "makenot.work");
2629 }
2630
2631 // WHOIS check tests
2632
2633 #[tokio::test]
2634 async fn whois_check_insert_and_query() {
2635 let pool = db::connect_in_memory().await.unwrap();
2636
2637 let result = WhoisResult {
2638 target: "mnw".to_string(),
2639 domain: "makenot.work".to_string(),
2640 registrar: Some("Namecheap, Inc.".to_string()),
2641 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
2642 days_remaining: Some(261),
2643 nameservers: vec!["dns1.registrar-servers.com".to_string()],
2644 checked_at: "2026-03-15T00:00:00Z".to_string(),
2645 error: None,
2646 };
2647 db::insert_whois_check(&pool, &result).await.unwrap();
2648
2649 let latest = db::get_latest_whois_check(&pool, "mnw").await.unwrap();
2650 assert!(latest.is_some());
2651 let row = latest.unwrap();
2652 assert_eq!(row.domain, "makenot.work");
2653 assert_eq!(row.registrar.as_deref(), Some("Namecheap, Inc."));
2654 assert_eq!(row.days_remaining, Some(261));
2655 }
2656
2657 #[tokio::test]
2658 async fn synckit_fleet_check_insert_and_query() {
2659 let pool = db::connect_in_memory().await.unwrap();
2660
2661 let result = SyncKitFleetCheckResult {
2662 target: "mnw".to_string(),
2663 window_days: 30,
2664 devices: 15,
2665 versions: vec![
2666 SyncKitVersionSnapshot {
2667 client_version: Some("0.6.0".to_string()),
2668 devices: 12,
2669 last_seen_at: Some("2026-07-29T12:00:00Z".to_string()),
2670 },
2671 SyncKitVersionSnapshot {
2672 client_version: None,
2673 devices: 3,
2674 last_seen_at: Some("2026-07-20T09:00:00Z".to_string()),
2675 },
2676 ],
2677 checked_at: "2026-07-29T13:00:00Z".to_string(),
2678 error: None,
2679 };
2680 db::insert_synckit_fleet_check(&pool, &result)
2681 .await
2682 .unwrap();
2683
2684 let row = db::get_latest_synckit_fleet_check(&pool, "mnw")
2685 .await
2686 .unwrap()
2687 .expect("readout should be stored");
2688 assert_eq!(row.devices, 15);
2689 assert_eq!(row.window_days, 30);
2690 assert!(row.error.is_none());
2691
2692 let versions = row.version_list();
2693 assert_eq!(versions.len(), 2);
2694 assert_eq!(versions[0].client_version.as_deref(), Some("0.6.0"));
2695 assert_eq!(versions[0].devices, 12);
2696 // The unknown bucket must survive the JSON round trip as null, not as the
2697 // string "unknown": the readout distinguishes "no version reported" from a
2698 // client that literally called itself that.
2699 assert!(versions[1].client_version.is_none());
2700 assert_eq!(versions[1].devices, 3);
2701 }
2702
2703 #[tokio::test]
2704 async fn synckit_fleet_check_returns_latest() {
2705 let pool = db::connect_in_memory().await.unwrap();
2706
2707 for (devices, checked_at) in [(3, "2026-07-28T00:00:00Z"), (9, "2026-07-29T00:00:00Z")] {
2708 let result = SyncKitFleetCheckResult {
2709 target: "mnw".to_string(),
2710 window_days: 30,
2711 devices,
2712 versions: Vec::new(),
2713 checked_at: checked_at.to_string(),
2714 error: None,
2715 };
2716 db::insert_synckit_fleet_check(&pool, &result)
2717 .await
2718 .unwrap();
2719 }
2720
2721 // History is kept rather than overwritten, so the read must be the newest row.
2722 let row = db::get_latest_synckit_fleet_check(&pool, "mnw")
2723 .await
2724 .unwrap()
2725 .unwrap();
2726 assert_eq!(row.devices, 9);
2727 }
2728
2729 #[tokio::test]
2730 async fn synckit_fleet_check_stores_an_unavailable_readout() {
2731 let pool = db::connect_in_memory().await.unwrap();
2732
2733 // A failed readout is persisted, not dropped: otherwise the ledger keeps
2734 // serving the last good distribution and nothing shows that PoM went blind.
2735 let result = SyncKitFleetCheckResult {
2736 target: "mnw".to_string(),
2737 window_days: 0,
2738 devices: 0,
2739 versions: Vec::new(),
2740 checked_at: "2026-07-29T13:00:00Z".to_string(),
2741 error: Some("HTTP 401 (alerts ingest token rejected)".to_string()),
2742 };
2743 db::insert_synckit_fleet_check(&pool, &result)
2744 .await
2745 .unwrap();
2746
2747 let row = db::get_latest_synckit_fleet_check(&pool, "mnw")
2748 .await
2749 .unwrap()
2750 .unwrap();
2751 assert!(row.error.as_deref().unwrap().contains("401"));
2752 assert!(row.version_list().is_empty());
2753 }
2754
2755 #[tokio::test]
2756 async fn synckit_fleet_check_returns_none_for_unknown_target() {
2757 let pool = db::connect_in_memory().await.unwrap();
2758 assert!(
2759 db::get_latest_synckit_fleet_check(&pool, "nope")
2760 .await
2761 .unwrap()
2762 .is_none()
2763 );
2764 }
2765
2766 #[tokio::test]
2767 async fn whois_check_returns_latest() {
2768 let pool = db::connect_in_memory().await.unwrap();
2769
2770 let r1 = WhoisResult {
2771 target: "mnw".to_string(),
2772 domain: "makenot.work".to_string(),
2773 registrar: Some("Old Registrar".to_string()),
2774 expiry_date: Some("2026-06-01T00:00:00Z".to_string()),
2775 days_remaining: Some(78),
2776 nameservers: vec![],
2777 checked_at: "2026-03-15T00:00:00Z".to_string(),
2778 error: None,
2779 };
2780 let r2 = WhoisResult {
2781 target: "mnw".to_string(),
2782 domain: "makenot.work".to_string(),
2783 registrar: Some("New Registrar".to_string()),
2784 expiry_date: Some("2027-06-01T00:00:00Z".to_string()),
2785 days_remaining: Some(443),
2786 nameservers: vec![],
2787 checked_at: "2026-03-15T01:00:00Z".to_string(),
2788 error: None,
2789 };
2790 db::insert_whois_check(&pool, &r1).await.unwrap();
2791 db::insert_whois_check(&pool, &r2).await.unwrap();
2792
2793 let latest = db::get_latest_whois_check(&pool, "mnw")
2794 .await
2795 .unwrap()
2796 .unwrap();
2797 assert_eq!(latest.registrar.as_deref(), Some("New Registrar"));
2798 assert_eq!(latest.days_remaining, Some(443));
2799 }
2800
2801 #[tokio::test]
2802 async fn whois_check_returns_none_for_unknown_target() {
2803 let pool = db::connect_in_memory().await.unwrap();
2804
2805 let latest = db::get_latest_whois_check(&pool, "nonexistent")
2806 .await
2807 .unwrap();
2808 assert!(latest.is_none());
2809 }
2810
2811 #[tokio::test]
2812 async fn whois_check_error_stored() {
2813 let pool = db::connect_in_memory().await.unwrap();
2814
2815 let result = WhoisResult {
2816 target: "mnw".to_string(),
2817 domain: "makenot.work".to_string(),
2818 registrar: None,
2819 expiry_date: None,
2820 days_remaining: None,
2821 nameservers: vec![],
2822 checked_at: chrono::Utc::now().to_rfc3339(),
2823 error: Some("WHOIS connection timed out".to_string()),
2824 };
2825 db::insert_whois_check(&pool, &result).await.unwrap();
2826
2827 let latest = db::get_latest_whois_check(&pool, "mnw")
2828 .await
2829 .unwrap()
2830 .unwrap();
2831 assert_eq!(latest.error.as_deref(), Some("WHOIS connection timed out"));
2832 assert!(latest.registrar.is_none());
2833 }
2834
2835 #[tokio::test]
2836 async fn cors_check_insert_and_query() {
2837 let pool = db::connect_in_memory().await.unwrap();
2838
2839 let result = CorsCheckResult {
2840 target: "mnw".to_string(),
2841 url: "https://storage.example.com/bucket/probe".to_string(),
2842 origin: "https://makenot.work".to_string(),
2843 method: "PUT".to_string(),
2844 passes: true,
2845 checked_at: chrono::Utc::now().to_rfc3339(),
2846 error: None,
2847 };
2848 db::insert_cors_check(&pool, &result).await.unwrap();
2849
2850 let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2851 assert_eq!(latest.len(), 1);
2852 assert!(latest[0].passes);
2853 assert_eq!(latest[0].url, "https://storage.example.com/bucket/probe");
2854 }
2855
2856 #[tokio::test]
2857 async fn cors_check_latest_per_url() {
2858 let pool = db::connect_in_memory().await.unwrap();
2859
2860 // Insert an old failing check
2861 let r1 = CorsCheckResult {
2862 target: "mnw".to_string(),
2863 url: "https://storage.example.com/bucket/probe".to_string(),
2864 origin: "https://makenot.work".to_string(),
2865 method: "PUT".to_string(),
2866 passes: false,
2867 checked_at: "2026-03-01T00:00:00Z".to_string(),
2868 error: Some("Missing Access-Control-Allow-Origin".to_string()),
2869 };
2870 db::insert_cors_check(&pool, &r1).await.unwrap();
2871
2872 // Insert a newer passing check for same URL
2873 let r2 = CorsCheckResult {
2874 target: "mnw".to_string(),
2875 url: "https://storage.example.com/bucket/probe".to_string(),
2876 origin: "https://makenot.work".to_string(),
2877 method: "PUT".to_string(),
2878 passes: true,
2879 checked_at: "2026-03-15T00:00:00Z".to_string(),
2880 error: None,
2881 };
2882 db::insert_cors_check(&pool, &r2).await.unwrap();
2883
2884 let latest = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2885 // Should return only the latest (passing) check per URL
2886 assert_eq!(latest.len(), 1);
2887 assert!(latest[0].passes);
2888 }
2889
2890 #[tokio::test]
2891 async fn cors_check_filters_by_target() {
2892 let pool = db::connect_in_memory().await.unwrap();
2893
2894 let r1 = CorsCheckResult {
2895 target: "mnw".to_string(),
2896 url: "https://storage.example.com/bucket/probe".to_string(),
2897 origin: "https://makenot.work".to_string(),
2898 method: "PUT".to_string(),
2899 passes: true,
2900 checked_at: chrono::Utc::now().to_rfc3339(),
2901 error: None,
2902 };
2903 let r2 = CorsCheckResult {
2904 target: "other".to_string(),
2905 url: "https://other.example.com/probe".to_string(),
2906 origin: "https://other.app".to_string(),
2907 method: "PUT".to_string(),
2908 passes: false,
2909 checked_at: chrono::Utc::now().to_rfc3339(),
2910 error: Some("Failed".to_string()),
2911 };
2912 db::insert_cors_check(&pool, &r1).await.unwrap();
2913 db::insert_cors_check(&pool, &r2).await.unwrap();
2914
2915 let mnw_checks = db::get_latest_cors_checks(&pool, "mnw").await.unwrap();
2916 assert_eq!(mnw_checks.len(), 1);
2917 assert_eq!(mnw_checks[0].target, "mnw");
2918 }
2919
2920 #[tokio::test]
2921 async fn prune_removes_old_dns_checks() {
2922 let pool = db::connect_in_memory().await.unwrap();
2923
2924 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2925 sqlx::query(
2926 "INSERT INTO dns_checks (target, name, record_type, expected, actual, matches, checked_at)
2927 VALUES (?, ?, ?, '[]', '[]', 1, ?)",
2928 )
2929 .bind("mnw")
2930 .bind("makenot.work")
2931 .bind("A")
2932 .bind(&old_time)
2933 .execute(&pool)
2934 .await
2935 .unwrap();
2936
2937 // Insert recent DNS check
2938 let recent = DnsCheckResult {
2939 target: "mnw".to_string(),
2940 name: "makenot.work".to_string(),
2941 record_type: pom::types::DnsRecordType::A,
2942 expected: vec![],
2943 actual: vec![],
2944 matches: true,
2945 checked_at: chrono::Utc::now().to_rfc3339(),
2946 error: None,
2947 };
2948 db::insert_dns_check(&pool, &recent).await.unwrap();
2949
2950 let result = db::prune_old_records(&pool, 30).await.unwrap();
2951 assert_eq!(result.dns, 1);
2952
2953 let remaining = db::get_latest_dns_checks(&pool, "mnw").await.unwrap();
2954 assert_eq!(remaining.len(), 1);
2955 }
2956
2957 #[tokio::test]
2958 async fn prune_removes_old_whois_checks() {
2959 let pool = db::connect_in_memory().await.unwrap();
2960
2961 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
2962 sqlx::query("INSERT INTO whois_checks (target, domain, checked_at) VALUES (?, ?, ?)")
2963 .bind("mnw")
2964 .bind("makenot.work")
2965 .bind(&old_time)
2966 .execute(&pool)
2967 .await
2968 .unwrap();
2969
2970 // Insert recent WHOIS check
2971 let recent = WhoisResult {
2972 target: "mnw".to_string(),
2973 domain: "makenot.work".to_string(),
2974 registrar: None,
2975 expiry_date: None,
2976 days_remaining: None,
2977 nameservers: vec![],
2978 checked_at: chrono::Utc::now().to_rfc3339(),
2979 error: None,
2980 };
2981 db::insert_whois_check(&pool, &recent).await.unwrap();
2982
2983 let result = db::prune_old_records(&pool, 30).await.unwrap();
2984 assert_eq!(result.whois, 1);
2985
2986 let remaining = db::get_latest_whois_check(&pool, "mnw").await.unwrap();
2987 assert!(remaining.is_some());
2988 }
2989
2990 // DNS/WHOIS API tests
2991
2992 #[tokio::test]
2993 async fn api_status_includes_dns_status() {
2994 let pool = db::connect_in_memory().await.unwrap();
2995 let config: pom::config::Config = toml::from_str(
2996 r#"
2997 [targets.mnw]
2998 label = "MakeNotWork"
2999 [targets.mnw.health]
3000 url = "https://makenot.work/health"
3001 [[targets.mnw.dns]]
3002 name = "makenot.work"
3003 record_type = "A"
3004 expected = ["5.78.144.244"]
3005 "#,
3006 )
3007 .unwrap();
3008 let app = pom::api::router(pool.clone(), config, None);
3009
3010 // Insert DNS check data
3011 let dns_result = DnsCheckResult {
3012 target: "mnw".to_string(),
3013 name: "makenot.work".to_string(),
3014 record_type: pom::types::DnsRecordType::A,
3015 expected: vec!["5.78.144.244".to_string()],
3016 actual: vec!["5.78.144.244".to_string()],
3017 matches: true,
3018 checked_at: chrono::Utc::now().to_rfc3339(),
3019 error: None,
3020 };
3021 db::insert_dns_check(&pool, &dns_result).await.unwrap();
3022
3023 let (status, json) = api_get(&app, "/api/status/mnw").await;
3024 assert_eq!(status, 200);
3025 let dns = json["dns_status"].as_array().unwrap();
3026 assert_eq!(dns.len(), 1);
3027 assert_eq!(dns[0]["name"], "makenot.work");
3028 assert_eq!(dns[0]["matches"], true);
3029 }
3030
3031 #[tokio::test]
3032 async fn api_status_omits_empty_dns_status() {
3033 let pool = db::connect_in_memory().await.unwrap();
3034 let config = test_config();
3035 let app = pom::api::router(pool, config, None);
3036
3037 let (status, json) = api_get(&app, "/api/status/mnw").await;
3038 assert_eq!(status, 200);
3039 assert!(json.get("dns_status").is_none());
3040 }
3041
3042 #[tokio::test]
3043 async fn api_status_includes_whois() {
3044 let pool = db::connect_in_memory().await.unwrap();
3045 let config = test_config();
3046 let app = pom::api::router(pool.clone(), config, None);
3047
3048 let whois_result = WhoisResult {
3049 target: "mnw".to_string(),
3050 domain: "makenot.work".to_string(),
3051 registrar: Some("Namecheap, Inc.".to_string()),
3052 expiry_date: Some("2026-12-01T12:00:00Z".to_string()),
3053 days_remaining: Some(261),
3054 nameservers: vec!["ns1.example.com".to_string()],
3055 checked_at: chrono::Utc::now().to_rfc3339(),
3056 error: None,
3057 };
3058 db::insert_whois_check(&pool, &whois_result).await.unwrap();
3059
3060 let (status, json) = api_get(&app, "/api/status/mnw").await;
3061 assert_eq!(status, 200);
3062 assert!(json["whois"].is_object());
3063 assert_eq!(json["whois"]["domain"], "makenot.work");
3064 assert_eq!(json["whois"]["days_remaining"], 261);
3065 }
3066
3067 #[tokio::test]
3068 async fn api_status_omits_whois_when_none() {
3069 let pool = db::connect_in_memory().await.unwrap();
3070 let config = test_config();
3071 let app = pom::api::router(pool, config, None);
3072
3073 let (status, json) = api_get(&app, "/api/status/mnw").await;
3074 assert_eq!(status, 200);
3075 assert!(json.get("whois").is_none());
3076 }
3077
3078 // DNS/WHOIS config parsing tests
3079
3080 #[test]
3081 fn config_with_dns_records_parses() {
3082 let toml_str = r#"
3083 [targets.mnw]
3084 label = "MakeNotWork"
3085
3086 [[targets.mnw.dns]]
3087 name = "makenot.work"
3088 record_type = "A"
3089 expected = ["5.78.144.244"]
3090
3091 [[targets.mnw.dns]]
3092 name = "forums.makenot.work"
3093 record_type = "A"
3094 expected = ["5.78.144.244"]
3095 "#;
3096 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3097 let mnw = config.get_target("mnw").unwrap();
3098 assert_eq!(mnw.dns.len(), 2);
3099 assert_eq!(mnw.dns[0].name, "makenot.work");
3100 assert_eq!(mnw.dns[0].record_type, pom::types::DnsRecordType::A);
3101 assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
3102 assert_eq!(mnw.dns[1].name, "forums.makenot.work");
3103 }
3104
3105 #[test]
3106 fn config_with_whois_parses() {
3107 let toml_str = r#"
3108 [targets.mnw]
3109 label = "MakeNotWork"
3110
3111 [targets.mnw.whois]
3112 domain = "makenot.work"
3113 warn_days = 60
3114 "#;
3115 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3116 let mnw = config.get_target("mnw").unwrap();
3117 let whois = mnw.whois.as_ref().unwrap();
3118 assert_eq!(whois.domain, "makenot.work");
3119 assert_eq!(whois.warn_days, 60);
3120 }
3121
3122 #[test]
3123 fn config_whois_default_warn_days() {
3124 let toml_str = r#"
3125 [targets.mnw]
3126 label = "MakeNotWork"
3127
3128 [targets.mnw.whois]
3129 domain = "makenot.work"
3130 "#;
3131 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3132 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
3133 assert_eq!(whois.warn_days, 30);
3134 }
3135
3136 #[test]
3137 fn config_with_cors_parses() {
3138 let toml_str = r#"
3139 [targets.mnw]
3140 label = "MakeNotWork"
3141
3142 [[targets.mnw.cors]]
3143 url = "https://example.com/bucket/probe"
3144 origin = "https://myapp.com"
3145 method = "PUT"
3146
3147 [[targets.mnw.cors]]
3148 url = "https://example.com/bucket/probe2"
3149 origin = "https://myapp.com"
3150 "#;
3151 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3152 let mnw = config.get_target("mnw").unwrap();
3153 assert_eq!(mnw.cors.len(), 2);
3154 assert_eq!(mnw.cors[0].url, "https://example.com/bucket/probe");
3155 assert_eq!(mnw.cors[0].origin, "https://myapp.com");
3156 assert_eq!(mnw.cors[0].method, "PUT");
3157 // Second entry uses default method
3158 assert_eq!(mnw.cors[1].method, "PUT");
3159 }
3160
3161 #[test]
3162 fn config_no_cors_defaults_to_empty() {
3163 let toml_str = r#"
3164 [targets.mnw]
3165 label = "MakeNotWork"
3166 "#;
3167 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3168 let mnw = config.get_target("mnw").unwrap();
3169 assert!(mnw.cors.is_empty());
3170 }
3171
3172 #[test]
3173 fn config_no_dns_defaults_to_empty() {
3174 let toml_str = r#"
3175 [targets.mnw]
3176 label = "MakeNotWork"
3177 "#;
3178 let config: pom::config::Config = toml::from_str(toml_str).unwrap();
3179 let mnw = config.get_target("mnw").unwrap();
3180 assert!(mnw.dns.is_empty());
3181 assert!(mnw.whois.is_none());
3182 }
3183
3184 // Dashboard tests
3185
3186 #[tokio::test]
3187 async fn dashboard_enabled_serves_html() {
3188 let pool = db::connect_in_memory().await.unwrap();
3189 let mut config = test_config();
3190 config.serve.dashboard = true;
3191 let app = pom::api::router(pool, config, None);
3192
3193 let (status, body) = get_body(&app, "/").await;
3194 assert_eq!(status, 200);
3195 assert!(body.contains("<!DOCTYPE html>"), "should contain doctype");
3196 assert!(body.contains("PoM"), "should contain PoM title");
3197 }
3198
3199 #[tokio::test]
3200 async fn dashboard_disabled_returns_404() {
3201 let pool = db::connect_in_memory().await.unwrap();
3202 let config = test_config(); // dashboard defaults to false
3203 let app = pom::api::router(pool, config, None);
3204
3205 let resp = app.clone().oneshot(get_req("/")).await.unwrap();
3206 assert_eq!(resp.status().as_u16(), 404);
3207 }
3208
3209 #[tokio::test]
3210 async fn dashboard_does_not_embed_api_token() {
3211 // SERIOUS #4: the served dashboard page must never contain the api_token,
3212 // it authenticates via the httpOnly pom_dash cookie instead.
3213 let pool = db::connect_in_memory().await.unwrap();
3214 let mut config = test_config();
3215 config.serve.dashboard = true;
3216 config.serve.api_token = Some("test-secret-token-42".to_string());
3217 let app = pom::api::router(pool, config, None);
3218
3219 let (status, body) = get_body(&app, "/").await;
3220 assert_eq!(status, 200);
3221 assert!(
3222 !body.contains("test-secret-token-42"),
3223 "the api_token must not appear in the page"
3224 );
3225 assert!(
3226 !body.contains("API_TOKEN"),
3227 "no token constant should be embedded in the JS"
3228 );
3229 }
3230
3231 #[tokio::test]
3232 async fn dashboard_shows_mesh_section_when_mesh_enabled() {
3233 let pool = db::connect_in_memory().await.unwrap();
3234 let mut config = test_config();
3235 config.serve.dashboard = true;
3236 let mesh = test_mesh();
3237 let app = pom::api::router(pool, config, Some(mesh));
3238
3239 let (status, body) = get_body(&app, "/").await;
3240 assert_eq!(status, 200);
3241 assert!(
3242 body.contains("Peer Mesh"),
3243 "should contain mesh section title"
3244 );
3245 assert!(
3246 body.contains("HAS_MESH = true"),
3247 "should set HAS_MESH to true"
3248 );
3249 }
3250
3251 #[tokio::test]
3252 async fn dashboard_no_mesh_section_when_mesh_disabled() {
3253 let pool = db::connect_in_memory().await.unwrap();
3254 let mut config = test_config();
3255 config.serve.dashboard = true;
3256 let app = pom::api::router(pool, config, None);
3257
3258 let (status, body) = get_body(&app, "/").await;
3259 assert_eq!(status, 200);
3260 assert!(
3261 body.contains("HAS_MESH = false"),
3262 "should set HAS_MESH to false"
3263 );
3264 }
3265
3266 // Per-test detail tracking
3267
3268 #[tokio::test]
3269 async fn insert_and_query_test_details() {
3270 let pool = db::connect_in_memory().await.unwrap();
3271
3272 let run = TestRun {
3273 id: None,
3274 target: "mnw".to_string(),
3275 started_at: "2026-03-16T00:00:00Z".to_string(),
3276 finished_at: Some("2026-03-16T00:02:00Z".to_string()),
3277 duration_secs: Some(120),
3278 exit_code: Some(0),
3279 passed: true,
3280 summary: TestSummary {
3281 steps: vec![],
3282 total_passed: Some(3),
3283 total_failed: Some(0),
3284 details: vec![
3285 TestDetail {
3286 test_name: "foo::bar".to_string(),
3287 passed: true,
3288 },
3289 TestDetail {
3290 test_name: "foo::baz".to_string(),
3291 passed: true,
3292 },
3293 TestDetail {
3294 test_name: "foo::qux".to_string(),
3295 passed: true,
3296 },
3297 ],
3298 },
3299 raw_output: String::new(),
3300 filter: None,
3301 };
3302
3303 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3304 db::insert_test_details(&pool, run_id, &run.summary.details)
3305 .await
3306 .unwrap();
3307
3308 // Verify via regression detection (no previous run = no regressions)
3309 let regressions = db::get_test_regressions(&pool, "mnw", run_id)
3310 .await
3311 .unwrap();
3312 assert!(regressions.is_empty());
3313 }
3314
3315 #[tokio::test]
3316 async fn regression_detection_finds_newly_failing_tests() {
3317 let pool = db::connect_in_memory().await.unwrap();
3318
3319 // First run: all pass
3320 let run1 = TestRun {
3321 id: None,
3322 target: "mnw".to_string(),
3323 started_at: "2026-03-16T00:00:00Z".to_string(),
3324 finished_at: Some("2026-03-16T00:02:00Z".to_string()),
3325 duration_secs: Some(120),
3326 exit_code: Some(0),
3327 passed: true,
3328 summary: TestSummary {
3329 steps: vec![],
3330 total_passed: Some(3),
3331 total_failed: Some(0),
3332 details: vec![
3333 TestDetail {
3334 test_name: "foo::bar".to_string(),
3335 passed: true,
3336 },
3337 TestDetail {
3338 test_name: "foo::baz".to_string(),
3339 passed: true,
3340 },
3341 TestDetail {
3342 test_name: "foo::qux".to_string(),
3343 passed: true,
3344 },
3345 ],
3346 },
3347 raw_output: String::new(),
3348 filter: None,
3349 };
3350
3351 let run1_id = db::insert_test_run(&pool, &run1).await.unwrap();
3352 db::insert_test_details(&pool, run1_id, &run1.summary.details)
3353 .await
3354 .unwrap();
3355
3356 // Second run: foo::baz fails
3357 let run2 = TestRun {
3358 id: None,
3359 target: "mnw".to_string(),
3360 started_at: "2026-03-16T00:05:00Z".to_string(),
3361 finished_at: Some("2026-03-16T00:07:00Z".to_string()),
3362 duration_secs: Some(120),
3363 exit_code: Some(1),
3364 passed: false,
3365 summary: TestSummary {
3366 steps: vec![],
3367 total_passed: Some(2),
3368 total_failed: Some(1),
3369 details: vec![
3370 TestDetail {
3371 test_name: "foo::bar".to_string(),
3372 passed: true,
3373 },
3374 TestDetail {
3375 test_name: "foo::baz".to_string(),
3376 passed: false,
3377 },
3378 TestDetail {
3379 test_name: "foo::qux".to_string(),
3380 passed: true,
3381 },
3382 ],
3383 },
3384 raw_output: String::new(),
3385 filter: None,
3386 };
3387
3388 let run2_id = db::insert_test_run(&pool, &run2).await.unwrap();
3389 db::insert_test_details(&pool, run2_id, &run2.summary.details)
3390 .await
3391 .unwrap();
3392
3393 let regressions = db::get_test_regressions(&pool, "mnw", run2_id)
3394 .await
3395 .unwrap();
3396 assert_eq!(regressions.len(), 1);
3397 assert_eq!(regressions[0], "foo::baz");
3398 }
3399
3400 #[tokio::test]
3401 async fn regression_ignores_already_failing_tests() {
3402 let pool = db::connect_in_memory().await.unwrap();
3403
3404 // Both runs: foo::baz fails
3405 for (i, ts) in ["00:00:00", "00:05:00"].iter().enumerate() {
3406 let run = TestRun {
3407 id: None,
3408 target: "mnw".to_string(),
3409 started_at: format!("2026-03-16T{ts}Z"),
3410 finished_at: None,
3411 duration_secs: Some(120),
3412 exit_code: Some(1),
3413 passed: false,
3414 summary: TestSummary {
3415 steps: vec![],
3416 total_passed: Some(2),
3417 total_failed: Some(1),
3418 details: vec![
3419 TestDetail {
3420 test_name: "foo::bar".to_string(),
3421 passed: true,
3422 },
3423 TestDetail {
3424 test_name: "foo::baz".to_string(),
3425 passed: false,
3426 },
3427 ],
3428 },
3429 raw_output: String::new(),
3430 filter: None,
3431 };
3432
3433 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3434 db::insert_test_details(&pool, run_id, &run.summary.details)
3435 .await
3436 .unwrap();
3437
3438 if i == 1 {
3439 // Second run, baz was already failing, not a regression
3440 let regressions = db::get_test_regressions(&pool, "mnw", run_id)
3441 .await
3442 .unwrap();
3443 assert!(regressions.is_empty());
3444 }
3445 }
3446 }
3447
3448 // Test duration drift detection
3449
3450 #[tokio::test]
3451 async fn test_duration_drift_detected() {
3452 use pom::checks::drift::detect_test_duration_drift;
3453
3454 // 10 baseline runs at 60s, 3 recent runs at 120s (2x baseline > 1.5x threshold)
3455 let mut durations: Vec<(String, i64)> = Vec::new();
3456 // Most recent first
3457 for i in 0..3 {
3458 durations.push((format!("2026-03-16T00:{:02}:00Z", 12 - i), 120));
3459 }
3460 for i in 0..10 {
3461 durations.push((format!("2026-03-16T00:{:02}:00Z", 9 - i), 60));
3462 }
3463
3464 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3465 assert!(drift.is_some());
3466 let msg = drift.unwrap();
3467 assert!(msg.contains("drift"), "drift message: {msg}");
3468 }
3469
3470 #[tokio::test]
3471 async fn test_duration_no_drift_when_stable() {
3472 use pom::checks::drift::detect_test_duration_drift;
3473
3474 // All runs at ~60s
3475 let mut durations: Vec<(String, i64)> = Vec::new();
3476 for i in 0..13 {
3477 durations.push((format!("2026-03-16T00:{:02}:00Z", 12 - i), 60));
3478 }
3479
3480 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3481 assert!(drift.is_none());
3482 }
3483
3484 #[tokio::test]
3485 async fn test_duration_drift_not_enough_data() {
3486 use pom::checks::drift::detect_test_duration_drift;
3487
3488 // Only 5 runs (need 13 for baseline 10 + recent 3)
3489 let durations: Vec<(String, i64)> = (0..5)
3490 .map(|i| (format!("2026-03-16T00:{i:02}:00Z"), 120))
3491 .collect();
3492
3493 let drift = detect_test_duration_drift(&durations, 10, 3, 1.5);
3494 assert!(drift.is_none());
3495 }
3496
3497 #[tokio::test]
3498 async fn get_test_durations_returns_ordered() {
3499 let pool = db::connect_in_memory().await.unwrap();
3500
3501 for (i, secs) in [60, 80, 100].iter().enumerate() {
3502 let run = TestRun {
3503 id: None,
3504 target: "mnw".to_string(),
3505 started_at: format!("2026-03-16T00:{i:02}:00Z"),
3506 finished_at: None,
3507 duration_secs: Some(*secs),
3508 exit_code: Some(0),
3509 passed: true,
3510 summary: TestSummary {
3511 steps: vec![],
3512 total_passed: None,
3513 total_failed: None,
3514 details: vec![],
3515 },
3516 raw_output: String::new(),
3517 filter: None,
3518 };
3519 db::insert_test_run(&pool, &run).await.unwrap();
3520 }
3521
3522 let durations = db::get_test_durations(&pool, "mnw", 10).await.unwrap();
3523 assert_eq!(durations.len(), 3);
3524 // Most recent first
3525 assert_eq!(durations[0].1, 100);
3526 assert_eq!(durations[2].1, 60);
3527 }
3528
3529 // Uptime percent tests
3530
3531 #[tokio::test]
3532 async fn uptime_percent_all_operational() {
3533 let pool = db::connect_in_memory().await.unwrap();
3534
3535 for i in 0..10 {
3536 let snapshot = HealthSnapshot {
3537 id: None,
3538 target: "mnw".to_string(),
3539 status: HealthStatus::Operational,
3540 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3541 response_time_ms: 100,
3542 details: None,
3543 error: None,
3544 };
3545 db::insert_health_check(&pool, &snapshot).await.unwrap();
3546 }
3547
3548 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3549 assert_eq!(pct, Some(100.0));
3550 }
3551
3552 #[tokio::test]
3553 async fn uptime_percent_mixed() {
3554 let pool = db::connect_in_memory().await.unwrap();
3555
3556 // 8 operational
3557 for i in 0..8 {
3558 let snapshot = HealthSnapshot {
3559 id: None,
3560 target: "mnw".to_string(),
3561 status: HealthStatus::Operational,
3562 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3563 response_time_ms: 100,
3564 details: None,
3565 error: None,
3566 };
3567 db::insert_health_check(&pool, &snapshot).await.unwrap();
3568 }
3569
3570 // 2 error
3571 for i in 8..10 {
3572 let snapshot = HealthSnapshot {
3573 id: None,
3574 target: "mnw".to_string(),
3575 status: HealthStatus::Error,
3576 checked_at: (chrono::Utc::now() - chrono::Duration::hours(i)).to_rfc3339(),
3577 response_time_ms: 0,
3578 details: None,
3579 error: Some("down".to_string()),
3580 };
3581 db::insert_health_check(&pool, &snapshot).await.unwrap();
3582 }
3583
3584 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3585 assert!(pct.is_some());
3586 let p = pct.unwrap();
3587 assert!((p - 80.0).abs() < 0.01, "expected ~80.0, got {p}");
3588 }
3589
3590 #[tokio::test]
3591 async fn uptime_percent_no_data() {
3592 let pool = db::connect_in_memory().await.unwrap();
3593
3594 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3595 assert_eq!(pct, None);
3596 }
3597
3598 #[tokio::test]
3599 async fn uptime_percent_only_old_data() {
3600 let pool = db::connect_in_memory().await.unwrap();
3601
3602 // Insert checks from 48 hours ago
3603 for i in 0..5 {
3604 let snapshot = HealthSnapshot {
3605 id: None,
3606 target: "mnw".to_string(),
3607 status: HealthStatus::Operational,
3608 checked_at: (chrono::Utc::now() - chrono::Duration::hours(48 + i)).to_rfc3339(),
3609 response_time_ms: 100,
3610 details: None,
3611 error: None,
3612 };
3613 db::insert_health_check(&pool, &snapshot).await.unwrap();
3614 }
3615
3616 // Query last 24h, should find nothing
3617 let pct = db::get_uptime_percent(&pool, "mnw", 24).await.unwrap();
3618 assert_eq!(pct, None);
3619 }
3620
3621 // Prune test_details orphan cleanup
3622
3623 #[tokio::test]
3624 async fn prune_cascades_test_details_with_deleted_run() {
3625 let pool = db::connect_in_memory().await.unwrap();
3626
3627 // Insert an old test run with details
3628 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
3629 let run = TestRun {
3630 id: None,
3631 target: "mnw".to_string(),
3632 started_at: old_time,
3633 finished_at: None,
3634 duration_secs: Some(60),
3635 exit_code: Some(0),
3636 passed: true,
3637 summary: TestSummary {
3638 steps: vec![],
3639 total_passed: Some(2),
3640 total_failed: Some(0),
3641 details: vec![
3642 TestDetail {
3643 test_name: "test_a".to_string(),
3644 passed: true,
3645 },
3646 TestDetail {
3647 test_name: "test_b".to_string(),
3648 passed: true,
3649 },
3650 ],
3651 },
3652 raw_output: String::new(),
3653 filter: None,
3654 };
3655 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3656 db::insert_test_details(&pool, run_id, &run.summary.details)
3657 .await
3658 .unwrap();
3659
3660 // Verify details exist
3661 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3662 .bind(run_id.0)
3663 .fetch_one(&pool)
3664 .await
3665 .unwrap();
3666 assert_eq!(count.0, 2);
3667
3668 // Prune with 1-day retention, the old run gets deleted, CASCADE removes details
3669 let result = db::prune_old_records(&pool, 1).await.unwrap();
3670 assert_eq!(result.tests, 1);
3671
3672 // Verify details are gone (removed by ON DELETE CASCADE)
3673 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3674 .bind(run_id.0)
3675 .fetch_one(&pool)
3676 .await
3677 .unwrap();
3678 assert_eq!(count.0, 0);
3679 }
3680
3681 #[tokio::test]
3682 async fn prune_cleans_up_orphaned_test_details() {
3683 let pool = db::connect_in_memory().await.unwrap();
3684
3685 // Create a real test run, add details, then delete the run directly to create orphans.
3686 // (FK constraints prevent inserting with a non-existent run_id.)
3687 let run = TestRun {
3688 id: None,
3689 target: "mnw".to_string(),
3690 started_at: chrono::Utc::now().to_rfc3339(),
3691 finished_at: None,
3692 duration_secs: Some(60),
3693 exit_code: Some(0),
3694 passed: true,
3695 summary: TestSummary {
3696 steps: vec![],
3697 total_passed: Some(1),
3698 total_failed: Some(0),
3699 details: vec![TestDetail {
3700 test_name: "orphan_test".to_string(),
3701 passed: true,
3702 }],
3703 },
3704 raw_output: String::new(),
3705 filter: None,
3706 };
3707 let run_id = db::insert_test_run(&pool, &run).await.unwrap();
3708 db::insert_test_details(&pool, run_id, &run.summary.details)
3709 .await
3710 .unwrap();
3711
3712 // Disable FK enforcement temporarily to delete the run without cascading
3713 sqlx::query("PRAGMA foreign_keys = OFF")
3714 .execute(&pool)
3715 .await
3716 .unwrap();
3717 sqlx::query("DELETE FROM test_runs WHERE id = ?")
3718 .bind(run_id.0)
3719 .execute(&pool)
3720 .await
3721 .unwrap();
3722 sqlx::query("PRAGMA foreign_keys = ON")
3723 .execute(&pool)
3724 .await
3725 .unwrap();
3726
3727 // Verify orphan exists
3728 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3729 .bind(run_id.0)
3730 .fetch_one(&pool)
3731 .await
3732 .unwrap();
3733 assert_eq!(count.0, 1);
3734
3735 // Prune, orphaned details should be cleaned up by the explicit orphan SQL
3736 let result = db::prune_old_records(&pool, 30).await.unwrap();
3737 assert_eq!(result.test_details, 1);
3738
3739 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM test_details WHERE run_id = ?")
3740 .bind(run_id.0)
3741 .fetch_one(&pool)
3742 .await
3743 .unwrap();
3744 assert_eq!(count.0, 0);
3745 }
3746
3747 // API test_duration_drift field
3748
3749 #[tokio::test]
3750 async fn api_status_target_includes_test_duration_drift() {
3751 let pool = db::connect_in_memory().await.unwrap();
3752 let config = test_config_with_tests();
3753 let app = pom::api::router(pool.clone(), config, None);
3754
3755 // Insert 13 test runs: 10 baseline at 60s, 3 recent at 120s
3756 for i in 0..10 {
3757 let run = TestRun {
3758 id: None,
3759 target: "mnw".to_string(),
3760 started_at: format!("2026-03-16T{i:02}:00:00Z"),
3761 finished_at: None,
3762 duration_secs: Some(60),
3763 exit_code: Some(0),
3764 passed: true,
3765 summary: TestSummary {
3766 steps: vec![],
3767 total_passed: None,
3768 total_failed: None,
3769 details: vec![],
3770 },
3771 raw_output: String::new(),
3772 filter: None,
3773 };
3774 db::insert_test_run(&pool, &run).await.unwrap();
3775 }
3776 for i in 10..13 {
3777 let run = TestRun {
3778 id: None,
3779 target: "mnw".to_string(),
3780 started_at: format!("2026-03-16T{i:02}:00:00Z"),
3781 finished_at: None,
3782 duration_secs: Some(120),
3783 exit_code: Some(0),
3784 passed: true,
3785 summary: TestSummary {
3786 steps: vec![],
3787 total_passed: None,
3788 total_failed: None,
3789 details: vec![],
3790 },
3791 raw_output: String::new(),
3792 filter: None,
3793 };
3794 db::insert_test_run(&pool, &run).await.unwrap();
3795 }
3796
3797 let (status, json) = api_get(&app, "/api/status/mnw").await;
3798 assert_eq!(status, 200);
3799 assert!(
3800 json["test_duration_drift"].is_string(),
3801 "expected test_duration_drift string, got: {json}"
3802 );
3803 let drift_msg = json["test_duration_drift"].as_str().unwrap();
3804 assert!(drift_msg.contains("drift"), "drift message: {drift_msg}");
3805 }
3806
3807 // pom versions roll-up
3808
3809 /// Insert one health check carrying the given version/sha for a target.
3810 async fn insert_version_health(
3811 pool: &sqlx::SqlitePool,
3812 target: &str,
3813 version: Option<&str>,
3814 git_sha: Option<&str>,
3815 checked_at: &str,
3816 ) {
3817 let snapshot = HealthSnapshot {
3818 id: None,
3819 target: target.to_string(),
3820 status: HealthStatus::Operational,
3821 checked_at: checked_at.to_string(),
3822 response_time_ms: 100,
3823 details: Some(HealthDetails {
3824 version: version.map(String::from),
3825 git_sha: git_sha.map(String::from),
3826 uptime: None,
3827 checks: None,
3828 monitoring: None,
3829 }),
3830 error: None,
3831 };
3832 db::insert_health_check(pool, &snapshot).await.unwrap();
3833 }
3834
3835 /// A throwaway git repo with `commits` commits on HEAD, each touching
3836 /// `subdir/f` when a subdir is given and a top-level file otherwise. Returns
3837 /// the repo path and every commit sha, oldest first.
3838 fn scratch_repo(
3839 name: &str,
3840 commits: usize,
3841 subdir: Option<&str>,
3842 ) -> (std::path::PathBuf, Vec<String>) {
3843 use std::process::Command;
3844
3845 let path = std::env::temp_dir().join(format!("pom_versions_{}_{name}", std::process::id()));
3846 let _ = std::fs::remove_dir_all(&path);
3847 std::fs::create_dir_all(&path).unwrap();
3848
3849 let git = |args: &[&str]| {
3850 let out = Command::new("git")
3851 .arg("-C")
3852 .arg(&path)
3853 .args(args)
3854 .output()
3855 .unwrap();
3856 assert!(
3857 out.status.success(),
3858 "git {args:?}: {}",
3859 String::from_utf8_lossy(&out.stderr)
3860 );
3861 String::from_utf8_lossy(&out.stdout).trim().to_string()
3862 };
3863
3864 git(&["init", "-b", "main"]);
3865 git(&["config", "user.email", "test@example.invalid"]);
3866 git(&["config", "user.name", "pom test"]);
3867
3868 let mut shas = Vec::new();
3869 for i in 0..commits {
3870 let file = match subdir {
3871 Some(d) => {
3872 std::fs::create_dir_all(path.join(d)).unwrap();
3873 path.join(d).join("f")
3874 }
3875 None => path.join("f"),
3876 };
3877 std::fs::write(&file, format!("{i}\n")).unwrap();
3878 git(&["add", "-A"]);
3879 git(&["commit", "-m", &format!("commit {i}")]);
3880 shas.push(git(&["rev-parse", "HEAD"]));
3881 }
3882
3883 (path, shas)
3884 }
3885
3886 #[tokio::test]
3887 async fn versions_rollup_reads_the_latest_health_per_target() {
3888 let pool = db::connect_in_memory().await.unwrap();
3889 let config: pom::config::Config = toml::from_str(
3890 r#"
3891 [targets.mnw]
3892 label = "MakeNotWork"
3893 [targets.mt]
3894 label = "Multithreaded"
3895 "#,
3896 )
3897 .unwrap();
3898
3899 insert_version_health(
3900 &pool,
3901 "mnw",
3902 Some("0.10.0"),
3903 Some("aaaa1111"),
3904 "2026-07-28T00:00:00Z",
3905 )
3906 .await;
3907 insert_version_health(
3908 &pool,
3909 "mnw",
3910 Some("0.11.0"),
3911 Some("6402bf4e"),
3912 "2026-07-29T00:00:00Z",
3913 )
3914 .await;
3915
3916 let rows = pom::versions::collect(&pool, &config).await.unwrap();
3917 assert_eq!(rows.len(), 2);
3918
3919 let mnw = rows.iter().find(|r| r.target == "mnw").unwrap();
3920 assert_eq!(mnw.label, "MakeNotWork");
3921 assert_eq!(mnw.version.as_deref(), Some("0.11.0"));
3922 assert_eq!(mnw.git_sha.as_deref(), Some("6402bf4e"));
3923 assert_eq!(mnw.checked_at.as_deref(), Some("2026-07-29T00:00:00Z"));
3924
3925 // Never checked, and no repo configured: blank, and not an error.
3926 let mt = rows.iter().find(|r| r.target == "mt").unwrap();
3927 assert!(mt.version.is_none() && mt.checked_at.is_none());
3928 assert!(mt.commits_behind.is_none() && mt.behind_error.is_none());
3929 }
3930
3931 #[tokio::test]
3932 async fn versions_counts_commits_behind_local_head() {
3933 let (path, shas) = scratch_repo("behind", 3, None);
3934 let pool = db::connect_in_memory().await.unwrap();
3935 let config: pom::config::Config = toml::from_str(&format!(
3936 r#"
3937 [targets.mnw]
3938 label = "MakeNotWork"
3939 [targets.mnw.repo]
3940 path = "{}"
3941 "#,
3942 path.display()
3943 ))
3944 .unwrap();
3945
3946 insert_version_health(
3947 &pool,
3948 "mnw",
3949 Some("0.11.0"),
3950 Some(&shas[0]),
3951 "2026-07-29T00:00:00Z",
3952 )
3953 .await;
3954
3955 let rows = pom::versions::collect(&pool, &config).await.unwrap();
3956 assert_eq!(rows[0].commits_behind, Some(2), "row: {:?}", rows[0]);
3957 assert!(rows[0].behind_error.is_none());
3958
3959 // Live sha == HEAD is zero behind, not a missing measurement.
3960 insert_version_health(
3961 &pool,
3962 "mnw",
3963 Some("0.11.1"),
3964 Some(&shas[2]),
3965 "2026-07-29T01:00:00Z",
3966 )
3967 .await;
3968 let rows = pom::versions::collect(&pool, &config).await.unwrap();
3969 assert_eq!(rows[0].commits_behind, Some(0));
3970
3971 std::fs::remove_dir_all(&path).unwrap();
3972 }
3973
3974 #[tokio::test]
3975 async fn versions_scopes_the_count_to_the_configured_subdir() {
3976 // Every commit here touches server/, then one lands outside it. Scoped to
3977 // server/, the trailing commit must not count against the deployed build.
3978 let (path, shas) = scratch_repo("subdir", 2, Some("server"));
3979 std::fs::write(path.join("unrelated"), "x\n").unwrap();
3980 let git = |args: &[&str]| {
3981 let out = std::process::Command::new("git")
3982 .arg("-C")
3983 .arg(&path)
3984 .args(args)
3985 .output()
3986 .unwrap();
3987 assert!(
3988 out.status.success(),
3989 "{}",
3990 String::from_utf8_lossy(&out.stderr)
3991 );
3992 };
3993 git(&["add", "-A"]);
3994 git(&["commit", "-m", "outside server"]);
3995
3996 let pool = db::connect_in_memory().await.unwrap();
3997 let config: pom::config::Config = toml::from_str(&format!(
3998 r#"
3999 [targets.mnw]
4000 label = "MakeNotWork"
4001 [targets.mnw.repo]
4002 path = "{}"
4003 subdir = "server"
4004 "#,
4005 path.display()
4006 ))
4007 .unwrap();
4008
4009 insert_version_health(
4010 &pool,
4011 "mnw",
4012 Some("0.11.0"),
4013 Some(&shas[0]),
4014 "2026-07-29T00:00:00Z",
4015 )
4016 .await;
4017
4018 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4019 assert_eq!(rows[0].commits_behind, Some(1), "row: {:?}", rows[0]);
4020
4021 std::fs::remove_dir_all(&path).unwrap();
4022 }
4023
4024 #[tokio::test]
4025 async fn versions_blanks_the_column_when_the_repo_is_not_reachable() {
4026 let pool = db::connect_in_memory().await.unwrap();
4027 let config: pom::config::Config = toml::from_str(
4028 r#"
4029 [targets.mnw]
4030 label = "MakeNotWork"
4031 [targets.mnw.repo]
4032 path = "/nonexistent/pom-versions-test"
4033 "#,
4034 )
4035 .unwrap();
4036
4037 insert_version_health(
4038 &pool,
4039 "mnw",
4040 Some("0.11.0"),
4041 Some("6402bf4e"),
4042 "2026-07-29T00:00:00Z",
4043 )
4044 .await;
4045
4046 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4047 assert!(rows[0].commits_behind.is_none());
4048 assert!(
4049 rows[0].behind_error.is_some(),
4050 "a failed count must say why"
4051 );
4052 // The rest of the row still reports.
4053 assert_eq!(rows[0].version.as_deref(), Some("0.11.0"));
4054 }
4055
4056 #[tokio::test]
4057 async fn versions_stays_quiet_about_a_target_that_has_never_been_checked() {
4058 // A repo is configured, but no health check has ever run. There is nothing
4059 // to report yet, and claiming the target "reports no git_sha" would blame
4060 // it for a check that never happened.
4061 let (path, _) = scratch_repo("nocheck", 1, None);
4062 let pool = db::connect_in_memory().await.unwrap();
4063 let config: pom::config::Config = toml::from_str(&format!(
4064 r#"
4065 [targets.mnw]
4066 label = "MakeNotWork"
4067 [targets.mnw.repo]
4068 path = "{}"
4069 "#,
4070 path.display()
4071 ))
4072 .unwrap();
4073
4074 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4075 assert!(rows[0].checked_at.is_none());
4076 assert!(rows[0].commits_behind.is_none());
4077 assert!(rows[0].behind_error.is_none(), "{:?}", rows[0].behind_error);
4078
4079 std::fs::remove_dir_all(&path).unwrap();
4080 }
4081
4082 #[tokio::test]
4083 async fn versions_says_when_a_target_reports_no_sha_to_anchor_on() {
4084 let (path, _) = scratch_repo("nosha", 1, None);
4085 let pool = db::connect_in_memory().await.unwrap();
4086 let config: pom::config::Config = toml::from_str(&format!(
4087 r#"
4088 [targets.mnw]
4089 label = "MakeNotWork"
4090 [targets.mnw.repo]
4091 path = "{}"
4092 "#,
4093 path.display()
4094 ))
4095 .unwrap();
4096
4097 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T00:00:00Z").await;
4098
4099 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4100 assert!(rows[0].commits_behind.is_none());
4101 assert!(rows[0].behind_error.as_deref().unwrap().contains("git_sha"));
4102
4103 std::fs::remove_dir_all(&path).unwrap();
4104 }
4105
4106 #[tokio::test]
4107 async fn versions_rejects_a_git_sha_that_git_would_read_as_an_option() {
4108 let (path, _) = scratch_repo("injection", 1, None);
4109 let pool = db::connect_in_memory().await.unwrap();
4110 let config: pom::config::Config = toml::from_str(&format!(
4111 r#"
4112 [targets.mnw]
4113 label = "MakeNotWork"
4114 [targets.mnw.repo]
4115 path = "{}"
4116 "#,
4117 path.display()
4118 ))
4119 .unwrap();
4120
4121 insert_version_health(
4122 &pool,
4123 "mnw",
4124 Some("0.11.0"),
4125 Some("--output=/tmp/pom-versions-pwned"),
4126 "2026-07-29T00:00:00Z",
4127 )
4128 .await;
4129
4130 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4131 assert!(rows[0].commits_behind.is_none());
4132 assert!(
4133 rows[0]
4134 .behind_error
4135 .as_deref()
4136 .unwrap()
4137 .contains("unusable")
4138 );
4139 assert!(!std::path::Path::new("/tmp/pom-versions-pwned").exists());
4140
4141 std::fs::remove_dir_all(&path).unwrap();
4142 }
4143
4144 #[test]
4145 fn health_details_written_before_git_sha_existed_still_read() {
4146 // Rows already in health_checks have no git_sha key. They must come back as
4147 // snapshots with a blank sha, not fail to deserialize and lose the version.
4148 let old = r#"{"version":"0.10.0","uptime":"3d","checks":null,"monitoring":null}"#;
4149 let details: HealthDetails = serde_json::from_str(old).unwrap();
4150 assert_eq!(details.version.as_deref(), Some("0.10.0"));
4151 assert!(details.git_sha.is_none());
4152 }
4153
4154 #[test]
4155 fn repo_path_must_be_absolute() {
4156 let toml = r#"
4157 [targets.mnw]
4158 label = "MakeNotWork"
4159 [targets.mnw.repo]
4160 path = "../server"
4161 "#;
4162 let tmp = std::env::temp_dir().join(format!("pom_repo_rel_{}.toml", std::process::id()));
4163 std::fs::write(&tmp, toml).unwrap();
4164 let result = pom::config::Config::load(Some(tmp.as_path()));
4165 std::fs::remove_file(&tmp).unwrap();
4166 let err = result.unwrap_err().to_string();
4167 assert!(err.contains("must be absolute"), "error: {err}");
4168 }
4169
4170 // Read-only orientation tools
4171
4172 /// A server whose config also knows one peer, so instance resolution has
4173 /// something to resolve and something to reject.
4174 fn orient_server(pool: sqlx::SqlitePool) -> PomServer {
4175 let config: pom::config::Config = toml::from_str(
4176 r#"
4177 [targets.mnw]
4178 label = "MakeNotWork"
4179 [targets.mnw.health]
4180 url = "https://makenot.work/api/health"
4181
4182 [peers.hetzner]
4183 address = "100.64.0.1:9100"
4184 token = "peer-token"
4185 "#,
4186 )
4187 .unwrap();
4188 PomServer::new(pool, config)
4189 }
4190
4191 fn instance_params(instance: Option<&str>) -> pom::tools::orient::InstanceParams {
4192 serde_json::from_value(serde_json::json!({ "instance": instance })).unwrap()
4193 }
4194
4195 #[tokio::test]
4196 async fn tool_status_table_reports_one_line_per_target() {
4197 let pool = db::connect_in_memory().await.unwrap();
4198 let server = orient_server(pool.clone());
4199 insert_version_health(
4200 &pool,
4201 "mnw",
4202 Some("0.11.0"),
4203 Some("6402bf4e"),
4204 "2026-07-29T18:00:00Z",
4205 )
4206 .await;
4207
4208 let out = server
4209 .status_table_impl(instance_params(None))
4210 .await
4211 .unwrap();
4212 assert!(out.contains("TARGET"), "{out}");
4213 let row = out
4214 .lines()
4215 .find(|l| l.starts_with("mnw"))
4216 .unwrap_or_else(|| panic!("no mnw row in:\n{out}"));
4217 // "ok" is the shared status vocabulary of the payload, not PoM's own
4218 // "operational": the table speaks the contract every source speaks.
4219 assert!(row.contains("ok"), "row: {row}");
4220 assert!(row.contains("0.11.0"), "row: {row}");
4221 }
4222
4223 #[tokio::test]
4224 async fn tool_status_table_puts_the_worst_target_first() {
4225 let pool = db::connect_in_memory().await.unwrap();
4226 let config: pom::config::Config = toml::from_str(
4227 r#"
4228 [targets.aaa]
4229 label = "Fine"
4230 [targets.aaa.health]
4231 url = "https://example.invalid/health"
4232 [targets.zzz]
4233 label = "Broken"
4234 [targets.zzz.health]
4235 url = "https://example.invalid/health"
4236 "#,
4237 )
4238 .unwrap();
4239 let server = PomServer::new(pool.clone(), config);
4240
4241 insert_version_health(&pool, "aaa", Some("1.0.0"), None, "2026-07-29T18:00:00Z").await;
4242 let broken = HealthSnapshot {
4243 id: None,
4244 target: "zzz".to_string(),
4245 status: HealthStatus::Unreachable,
4246 checked_at: "2026-07-29T18:00:00Z".to_string(),
4247 response_time_ms: 0,
4248 details: None,
4249 error: Some("connection refused".to_string()),
4250 };
4251 db::insert_health_check(&pool, &broken).await.unwrap();
4252
4253 let out = server
4254 .status_table_impl(instance_params(None))
4255 .await
4256 .unwrap();
4257 let first_target = out
4258 .lines()
4259 .find(|l| l.starts_with("aaa") || l.starts_with("zzz"))
4260 .unwrap();
4261 assert!(
4262 first_target.starts_with("zzz"),
4263 "the broken target must head the table:\n{out}"
4264 );
4265 assert!(first_target.contains("connection refused"), "{out}");
4266 }
4267
4268 #[tokio::test]
4269 async fn tool_target_status_lists_every_condition() {
4270 let pool = db::connect_in_memory().await.unwrap();
4271 let server = orient_server(pool.clone());
4272 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T18:00:00Z").await;
4273 db::insert_incident(&pool, "mnw", "operational", "degraded")
4274 .await
4275 .unwrap();
4276
4277 let params: pom::tools::orient::TargetInstanceParams =
4278 serde_json::from_value(serde_json::json!({ "target": "mnw" })).unwrap();
4279 let out = server.target_status_impl(params).await.unwrap();
4280 assert!(out.contains("health"), "{out}");
4281 assert!(out.contains("incident"), "{out}");
4282 assert!(out.contains("version: 0.11.0"), "{out}");
4283 }
4284
4285 #[tokio::test]
4286 async fn tool_target_status_names_the_known_targets_when_asked_for_a_stranger() {
4287 let pool = db::connect_in_memory().await.unwrap();
4288 let server = orient_server(pool);
4289
4290 let params: pom::tools::orient::TargetInstanceParams =
4291 serde_json::from_value(serde_json::json!({ "target": "nope" })).unwrap();
4292 let out = server.target_status_impl(params).await.unwrap();
4293 assert!(out.contains("Unknown target"), "{out}");
4294 assert!(out.contains("mnw"), "must list what it does know: {out}");
4295 }
4296
4297 #[tokio::test]
4298 async fn tool_incidents_reports_open_ones_and_other_failing_checks() {
4299 let pool = db::connect_in_memory().await.unwrap();
4300 let server = orient_server(pool.clone());
4301
4302 let out = server.incidents_impl(instance_params(None)).await.unwrap();
4303 assert!(out.contains("No open incidents"), "{out}");
4304
4305 db::insert_incident(&pool, "mnw", "operational", "error")
4306 .await
4307 .unwrap();
4308 let out = server.incidents_impl(instance_params(None)).await.unwrap();
4309 assert!(out.contains("mnw / incident"), "{out}");
4310 // The health condition is pending (no check recorded), which is not a
4311 // failure and must not be reported as one.
4312 assert!(!out.contains("mnw / health"), "{out}");
4313 }
4314
4315 #[tokio::test]
4316 async fn tool_versions_returns_the_roll_up() {
4317 let pool = db::connect_in_memory().await.unwrap();
4318 let server = orient_server(pool.clone());
4319 insert_version_health(
4320 &pool,
4321 "mnw",
4322 Some("0.11.0"),
4323 Some("6402bf4e"),
4324 "2026-07-29T18:00:00Z",
4325 )
4326 .await;
4327
4328 let out = server.versions_impl(instance_params(None)).await.unwrap();
4329 assert!(out.contains("TARGET"), "{out}");
4330 assert!(out.contains("0.11.0") && out.contains("6402bf4e"), "{out}");
4331 }
4332
4333 #[tokio::test]
4334 async fn tool_trends_reports_the_window_and_baseline() {
4335 let pool = db::connect_in_memory().await.unwrap();
4336 let server = orient_server(pool.clone());
4337 for i in 0..3 {
4338 let snapshot = HealthSnapshot {
4339 id: None,
4340 target: "mnw".to_string(),
4341 status: HealthStatus::Operational,
4342 checked_at: chrono::Utc::now().to_rfc3339(),
4343 response_time_ms: 100 + i,
4344 details: None,
4345 error: None,
4346 };
4347 db::insert_health_check(&pool, &snapshot).await.unwrap();
4348 }
4349
4350 let params: pom::tools::orient::TrendsParams =
4351 serde_json::from_value(serde_json::json!({ "target": "mnw" })).unwrap();
4352 let out = server.trends_impl(params).await.unwrap();
4353 assert!(out.contains("last 24h"), "{out}");
4354 assert!(out.contains("Window: avg"), "{out}");
4355
4356 let unknown: pom::tools::orient::TrendsParams =
4357 serde_json::from_value(serde_json::json!({ "target": "nope" })).unwrap();
4358 let out = server.trends_impl(unknown).await.unwrap();
4359 assert!(out.contains("Unknown target"), "{out}");
4360 }
4361
4362 #[tokio::test]
4363 async fn tool_unknown_instance_names_the_configured_peers() {
4364 let pool = db::connect_in_memory().await.unwrap();
4365 let server = orient_server(pool);
4366
4367 let err = server
4368 .status_table_impl(instance_params(Some("mars")))
4369 .await
4370 .unwrap_err()
4371 .to_string();
4372 assert!(err.contains("unknown instance"), "{err}");
4373 assert!(
4374 err.contains("hetzner"),
4375 "must list the peers it knows: {err}"
4376 );
4377 }
4378
4379 #[tokio::test]
4380 async fn tool_local_instance_needs_no_running_daemon() {
4381 // The whole point of reading the pool directly: `pom serve` is not up in
4382 // this test, and the local answer still comes back.
4383 let pool = db::connect_in_memory().await.unwrap();
4384 let server = orient_server(pool.clone());
4385 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T18:00:00Z").await;
4386
4387 for instance in [None, Some("local")] {
4388 let out = server
4389 .status_table_impl(instance_params(instance))
4390 .await
4391 .unwrap();
4392 assert!(out.contains("mnw"), "{out}");
4393 }
4394 }
4395
4396 #[tokio::test]
4397 async fn api_versions_serves_the_roll_up() {
4398 let pool = db::connect_in_memory().await.unwrap();
4399 let app = pom::api::router(pool.clone(), test_config(), None);
4400 insert_version_health(
4401 &pool,
4402 "mnw",
4403 Some("0.11.0"),
4404 Some("6402bf4e"),
4405 "2026-07-29T18:00:00Z",
4406 )
4407 .await;
4408
4409 let (status, json) = api_get(&app, "/api/versions").await;
4410 assert_eq!(status, 200);
4411 assert_eq!(json[0]["target"], "mnw");
4412 assert_eq!(json[0]["version"], "0.11.0");
4413 assert_eq!(json[0]["git_sha"], "6402bf4e");
4414 }
4415
4416 #[tokio::test]
4417 async fn versions_reports_when_the_live_version_was_first_seen() {
4418 let pool = db::connect_in_memory().await.unwrap();
4419 let config: pom::config::Config = toml::from_str(
4420 r#"
4421 [targets.mnw]
4422 label = "MakeNotWork"
4423 "#,
4424 )
4425 .unwrap();
4426
4427 insert_version_health(&pool, "mnw", Some("0.10.0"), None, "2026-07-20T00:00:00Z").await;
4428 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-27T00:00:00Z").await;
4429 // An unreachable check reports no version at all. It must not read as a
4430 // version change, or every blip would look like a fresh deploy.
4431 let blip = HealthSnapshot {
4432 id: None,
4433 target: "mnw".to_string(),
4434 status: HealthStatus::Unreachable,
4435 checked_at: "2026-07-28T00:00:00Z".to_string(),
4436 response_time_ms: 0,
4437 details: None,
4438 error: Some("timeout".to_string()),
4439 };
4440 db::insert_health_check(&pool, &blip).await.unwrap();
4441 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T00:00:00Z").await;
4442
4443 let rows = pom::versions::collect(&pool, &config).await.unwrap();
4444 assert_eq!(rows[0].version.as_deref(), Some("0.11.0"));
4445 assert_eq!(
4446 rows[0].version_since.as_deref(),
4447 Some("2026-07-27T00:00:00Z")
4448 );
4449 assert_eq!(rows[0].checked_at.as_deref(), Some("2026-07-29T00:00:00Z"));
4450 }
4451
4452 #[tokio::test]
4453 async fn versions_first_seen_moves_when_the_version_does() {
4454 let pool = db::connect_in_memory().await.unwrap();
4455 let config: pom::config::Config = toml::from_str(
4456 r#"
4457 [targets.mnw]
4458 label = "MakeNotWork"
4459 "#,
4460 )
4461 .unwrap();
4462
4463 insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-27T00:00:00Z").await;
4464 let before = pom::versions::collect(&pool, &config).await.unwrap();
4465 assert_eq!(
4466 before[0].version_since.as_deref(),
4467 Some("2026-07-27T00:00:00Z")
4468 );
4469
4470 insert_version_health(&pool, "mnw", Some("0.11.1"), None, "2026-07-29T12:00:00Z").await;
4471 let after = pom::versions::collect(&pool, &config).await.unwrap();
4472 assert_eq!(after[0].version.as_deref(), Some("0.11.1"));
4473 assert_eq!(
4474 after[0].version_since.as_deref(),
4475 Some("2026-07-29T12:00:00Z")
4476 );
4477 }
4478