Skip to main content

max / pom

24.3 KB · 701 lines History Blame Raw
1 //! Shared domain types — health snapshots, test runs, and target info.
2
3 use std::fmt;
4
5 use serde::{Deserialize, Serialize};
6
7 /// Alert category — identifies the type of alert being sent.
8 /// Stored as the `alert_type` column in the `alerts` table.
9 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 #[serde(rename_all = "snake_case")]
11 pub enum AlertCategory {
12 Health,
13 Recovery,
14 TlsExpiry,
15 TlsError,
16 TlsRecovery,
17 PeerMissing,
18 PeerRecovery,
19 RouteFailure,
20 RouteRecovery,
21 DnsMismatch,
22 DnsRecovery,
23 WhoisExpiry,
24 WhoisError,
25 LatencyDrift,
26 LatencyRecovery,
27 TestDurationDrift,
28 CorsFailure,
29 CorsRecovery,
30 MonitoringOffline,
31 MonitoringRecovery,
32 }
33
34 impl fmt::Display for AlertCategory {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 Self::Health => write!(f, "health"),
38 Self::Recovery => write!(f, "recovery"),
39 Self::TlsExpiry => write!(f, "tls_expiry"),
40 Self::TlsError => write!(f, "tls_error"),
41 Self::TlsRecovery => write!(f, "tls_recovery"),
42 Self::PeerMissing => write!(f, "peer_missing"),
43 Self::PeerRecovery => write!(f, "peer_recovery"),
44 Self::RouteFailure => write!(f, "route_failure"),
45 Self::RouteRecovery => write!(f, "route_recovery"),
46 Self::DnsMismatch => write!(f, "dns_mismatch"),
47 Self::DnsRecovery => write!(f, "dns_recovery"),
48 Self::WhoisExpiry => write!(f, "whois_expiry"),
49 Self::WhoisError => write!(f, "whois_error"),
50 Self::LatencyDrift => write!(f, "latency_drift"),
51 Self::LatencyRecovery => write!(f, "latency_recovery"),
52 Self::TestDurationDrift => write!(f, "test_duration_drift"),
53 Self::CorsFailure => write!(f, "cors_failure"),
54 Self::CorsRecovery => write!(f, "cors_recovery"),
55 Self::MonitoringOffline => write!(f, "monitoring_offline"),
56 Self::MonitoringRecovery => write!(f, "monitoring_recovery"),
57 }
58 }
59 }
60
61 impl std::str::FromStr for AlertCategory {
62 type Err = String;
63 fn from_str(s: &str) -> Result<Self, Self::Err> {
64 match s {
65 "health" => Ok(Self::Health),
66 "recovery" => Ok(Self::Recovery),
67 "tls_expiry" => Ok(Self::TlsExpiry),
68 "tls_error" => Ok(Self::TlsError),
69 "tls_recovery" => Ok(Self::TlsRecovery),
70 "peer_missing" => Ok(Self::PeerMissing),
71 "peer_recovery" => Ok(Self::PeerRecovery),
72 "route_failure" => Ok(Self::RouteFailure),
73 "route_recovery" => Ok(Self::RouteRecovery),
74 "dns_mismatch" => Ok(Self::DnsMismatch),
75 "dns_recovery" => Ok(Self::DnsRecovery),
76 "whois_expiry" => Ok(Self::WhoisExpiry),
77 "whois_error" => Ok(Self::WhoisError),
78 "latency_drift" => Ok(Self::LatencyDrift),
79 "latency_recovery" => Ok(Self::LatencyRecovery),
80 "test_duration_drift" => Ok(Self::TestDurationDrift),
81 "cors_failure" => Ok(Self::CorsFailure),
82 "cors_recovery" => Ok(Self::CorsRecovery),
83 "monitoring_offline" => Ok(Self::MonitoringOffline),
84 "monitoring_recovery" => Ok(Self::MonitoringRecovery),
85 other => Err(format!("unknown alert category: {other}")),
86 }
87 }
88 }
89
90 /// DNS record type for configuration and checks.
91 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92 #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
93 pub enum DnsRecordType {
94 A,
95 #[serde(rename = "AAAA")]
96 Aaaa,
97 #[serde(rename = "CNAME")]
98 Cname,
99 #[serde(rename = "MX")]
100 Mx,
101 #[serde(rename = "TXT")]
102 Txt,
103 }
104
105 impl fmt::Display for DnsRecordType {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 match self {
108 Self::A => write!(f, "A"),
109 Self::Aaaa => write!(f, "AAAA"),
110 Self::Cname => write!(f, "CNAME"),
111 Self::Mx => write!(f, "MX"),
112 Self::Txt => write!(f, "TXT"),
113 }
114 }
115 }
116
117 impl std::str::FromStr for DnsRecordType {
118 type Err = String;
119 fn from_str(s: &str) -> Result<Self, Self::Err> {
120 match s {
121 "A" => Ok(Self::A),
122 "AAAA" => Ok(Self::Aaaa),
123 "CNAME" => Ok(Self::Cname),
124 "MX" => Ok(Self::Mx),
125 "TXT" => Ok(Self::Txt),
126 other => Err(format!("unsupported DNS record type: {other}")),
127 }
128 }
129 }
130
131 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
132 #[serde(rename_all = "lowercase")]
133 pub enum HealthStatus {
134 /// All checks passed — endpoint responded with expected status and content.
135 Operational,
136 /// Check responded but with an unexpected status code or content mismatch.
137 Degraded,
138 /// Check responded with a server error (5xx).
139 Error,
140 /// Connection failed or timed out — endpoint did not respond at all.
141 Unreachable,
142 }
143
144 impl HealthStatus {
145 /// Short label for CLI output.
146 pub fn icon(&self) -> &'static str {
147 match self {
148 Self::Operational => "OK",
149 Self::Degraded => "WARN",
150 Self::Error => "ERR",
151 Self::Unreachable => "DOWN",
152 }
153 }
154 }
155
156 impl std::fmt::Display for HealthStatus {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::Operational => write!(f, "operational"),
160 Self::Degraded => write!(f, "degraded"),
161 Self::Error => write!(f, "error"),
162 Self::Unreachable => write!(f, "unreachable"),
163 }
164 }
165 }
166
167 impl std::str::FromStr for HealthStatus {
168 type Err = String;
169 fn from_str(s: &str) -> Result<Self, Self::Err> {
170 match s {
171 "operational" => Ok(Self::Operational),
172 "degraded" => Ok(Self::Degraded),
173 "error" => Ok(Self::Error),
174 "unreachable" => Ok(Self::Unreachable),
175 other => Err(format!("unknown health status: {other}")),
176 }
177 }
178 }
179
180 #[derive(Debug, Clone, Serialize, Deserialize)]
181 pub struct HealthSnapshot {
182 /// Database row ID. `None` before the snapshot is inserted into SQLite.
183 pub id: Option<i64>,
184 /// Config key identifying the monitored target (e.g. "mnw").
185 pub target: String,
186 /// Derived health status for this check cycle.
187 pub status: HealthStatus,
188 /// Timestamp of the check in RFC 3339 format (UTC).
189 pub checked_at: String,
190 /// Round-trip time for the HTTP health request, in milliseconds.
191 pub response_time_ms: i64,
192 /// Structured data extracted from the health endpoint response body, if any.
193 pub details: Option<HealthDetails>,
194 /// Human-readable error message when the check failed.
195 pub error: Option<String>,
196 }
197
198 #[derive(Debug, Clone, Serialize, Deserialize)]
199 pub struct HealthDetails {
200 /// Application version string reported by the health endpoint.
201 pub version: Option<String>,
202 /// Human-readable uptime string from the health endpoint (e.g. "3d 12h").
203 pub uptime: Option<String>,
204 /// Subsystem check results as freeform JSON (e.g. `{"db": "ok", "redis": "ok"}`).
205 pub checks: Option<serde_json::Value>,
206 /// Monitoring metadata as freeform JSON (e.g. `{"connections": 42, "queue_depth": 0}`).
207 pub monitoring: Option<serde_json::Value>,
208 }
209
210 #[derive(Debug, Clone, Serialize, Deserialize)]
211 pub struct TestRun {
212 /// Database row ID. `None` before the run is inserted into SQLite.
213 pub id: Option<i64>,
214 /// Config key identifying the target whose tests were run.
215 pub target: String,
216 /// When the test run began, in RFC 3339 format (UTC).
217 pub started_at: String,
218 /// When the test run completed. `None` if still in progress.
219 pub finished_at: Option<String>,
220 /// Wall-clock duration of the test run. `None` if still in progress.
221 pub duration_secs: Option<i64>,
222 /// Process exit code. `None` if the process was killed or timed out.
223 pub exit_code: Option<i32>,
224 /// Whether the overall test run passed.
225 pub passed: bool,
226 /// Parsed test step results and pass/fail counts.
227 pub summary: TestSummary,
228 /// Full stdout+stderr captured from the test command.
229 pub raw_output: String,
230 /// Optional test filter expression (e.g. a specific test name or module).
231 pub filter: Option<String>,
232 }
233
234 #[derive(Debug, Clone, Serialize, Deserialize)]
235 pub struct TestSummary {
236 /// Individual step results parsed from the test command output.
237 pub steps: Vec<StepResult>,
238 /// Total number of passing tests, if parseable from the output.
239 pub total_passed: Option<i64>,
240 /// Total number of failing tests, if parseable from the output.
241 pub total_failed: Option<i64>,
242 /// Per-test results parsed from individual `test <name> ... ok/FAILED` lines.
243 #[serde(default)]
244 pub details: Vec<TestDetail>,
245 }
246
247 #[derive(Debug, Clone, Serialize, Deserialize)]
248 pub struct TestDetail {
249 /// Fully qualified test name (e.g. "workflows::endorsements::toggle").
250 pub test_name: String,
251 /// Whether this individual test passed.
252 pub passed: bool,
253 }
254
255 #[derive(Debug, Clone, Serialize, Deserialize)]
256 pub struct StepResult {
257 /// Name or label of the test step (e.g. "check", "test", "clippy").
258 pub name: String,
259 /// Whether this step passed.
260 pub passed: bool,
261 }
262
263 #[derive(Debug, Clone, Serialize, Deserialize)]
264 pub struct TlsStatus {
265 /// Config key identifying the monitored target.
266 pub target: String,
267 /// Hostname checked for TLS (e.g. "makenot.work").
268 pub host: String,
269 /// TCP port used for the TLS connection (typically 443).
270 pub port: u16,
271 /// Whether the certificate chain is valid and trusted.
272 pub valid: bool,
273 /// Days until the leaf certificate expires. Negative if already expired.
274 pub days_remaining: i64,
275 /// Certificate "not before" date in RFC 3339 format.
276 pub not_before: String,
277 /// Certificate "not after" (expiry) date in RFC 3339 format.
278 pub not_after: String,
279 /// Certificate subject common name or distinguished name.
280 pub subject: String,
281 /// Certificate issuer organization or distinguished name.
282 pub issuer: String,
283 /// When this TLS check was performed, in RFC 3339 format (UTC).
284 pub checked_at: String,
285 /// Human-readable error if the TLS handshake or validation failed.
286 pub error: Option<String>,
287 }
288
289 #[derive(Debug, Clone, Serialize, Deserialize)]
290 pub struct TargetInfo {
291 /// Config key for this target (e.g. "mnw", "go").
292 pub name: String,
293 /// Human-readable display label (e.g. "MakeNotWork").
294 pub label: String,
295 /// Whether a health check URL is configured for this target.
296 pub has_health: bool,
297 /// Whether a test command is configured for this target.
298 pub has_tests: bool,
299 }
300
301 #[derive(Debug, Clone, Serialize, Deserialize)]
302 pub struct LatencyStats {
303 /// Minimum observed response time in milliseconds.
304 pub min_ms: i64,
305 /// Maximum observed response time in milliseconds.
306 pub max_ms: i64,
307 /// Arithmetic mean of response times in milliseconds.
308 pub avg_ms: f64,
309 /// 95th percentile response time in milliseconds.
310 pub p95_ms: i64,
311 /// Number of samples used to compute these statistics.
312 pub sample_count: i64,
313 }
314
315 #[derive(Debug, Clone, Serialize, Deserialize)]
316 pub struct LatencyBucket {
317 /// Start of this time bucket in RFC 3339 format (UTC).
318 pub period_start: String,
319 /// Minimum response time within this bucket, in milliseconds.
320 pub min_ms: i64,
321 /// Maximum response time within this bucket, in milliseconds.
322 pub max_ms: i64,
323 /// Mean response time within this bucket, in milliseconds.
324 pub avg_ms: f64,
325 /// 95th percentile response time within this bucket, in milliseconds.
326 pub p95_ms: i64,
327 /// Number of health checks that fell within this bucket.
328 pub sample_count: i64,
329 }
330
331 #[derive(Debug, Clone, Serialize, Deserialize)]
332 pub struct TestStaleness {
333 /// Whether the test results are considered stale. When `true`, `reason` is always `Some`.
334 pub stale: bool,
335 /// Human-readable explanation of why tests are stale (version mismatch, age, etc.).
336 /// Always `Some` when `stale` is `true`, always `None` when `stale` is `false`.
337 pub reason: Option<String>,
338 /// Version string currently reported by the health endpoint.
339 pub current_version: Option<String>,
340 /// Version that was deployed when the last test ran.
341 pub tested_version: Option<String>,
342 /// When the most recent test run started, in RFC 3339 format.
343 pub last_test_at: Option<String>,
344 /// Number of whole days since the last test run.
345 pub days_since_test: Option<i64>,
346 }
347
348 #[derive(Debug, Clone, Serialize, Deserialize)]
349 pub struct DnsCheckResult {
350 /// Config key identifying the monitored target.
351 pub target: String,
352 /// Queried hostname (e.g. "makenot.work").
353 pub name: String,
354 /// DNS record type (A, AAAA, CNAME, MX, TXT).
355 pub record_type: DnsRecordType,
356 /// Expected values from config.
357 pub expected: Vec<String>,
358 /// Actually resolved values.
359 pub actual: Vec<String>,
360 /// Whether all expected values were found in actual (expected ⊆ actual).
361 pub matches: bool,
362 /// When this check was performed, in RFC 3339 format (UTC).
363 pub checked_at: String,
364 /// Error message if resolution failed.
365 pub error: Option<String>,
366 }
367
368 #[derive(Debug, Clone, Serialize, Deserialize)]
369 pub struct WhoisResult {
370 /// Config key identifying the monitored target.
371 pub target: String,
372 /// Domain that was queried.
373 pub domain: String,
374 /// Domain registrar name, if parsed.
375 pub registrar: Option<String>,
376 /// Registration expiry date (RFC 3339 or raw date string).
377 pub expiry_date: Option<String>,
378 /// Days until expiry. Negative if already expired.
379 pub days_remaining: Option<i64>,
380 /// Nameservers from WHOIS response.
381 pub nameservers: Vec<String>,
382 /// When this check was performed, in RFC 3339 format (UTC).
383 pub checked_at: String,
384 /// Error message if WHOIS query failed.
385 pub error: Option<String>,
386 }
387
388 #[derive(Debug, Clone, Serialize, Deserialize)]
389 pub struct CorsCheckResult {
390 /// Config key identifying the monitored target.
391 pub target: String,
392 /// URL that was checked.
393 pub url: String,
394 /// Origin sent in the preflight request.
395 pub origin: String,
396 /// HTTP method sent in `Access-Control-Request-Method`.
397 pub method: String,
398 /// Whether the preflight response allows the origin and method.
399 pub passes: bool,
400 /// When this check was performed, in RFC 3339 format (UTC).
401 pub checked_at: String,
402 /// Error message if the preflight request failed.
403 pub error: Option<String>,
404 }
405
406 impl LatencyStats {
407 /// Compute latency statistics from a slice of response times.
408 /// Returns `None` if the slice is empty.
409 pub fn from_times(times: &[i64]) -> Option<Self> {
410 if times.is_empty() {
411 return None;
412 }
413 let mut sorted = times.to_vec();
414 sorted.sort_unstable();
415 let n = sorted.len();
416 let min_ms = sorted[0];
417 let max_ms = sorted[n - 1];
418 let sum: i64 = sorted.iter().sum();
419 let avg_ms = sum as f64 / n as f64;
420 let p95_idx = ((n as f64 * 0.95).ceil() as usize).saturating_sub(1).min(n - 1);
421 let p95_ms = sorted[p95_idx];
422 Some(Self {
423 min_ms,
424 max_ms,
425 avg_ms,
426 p95_ms,
427 sample_count: n as i64,
428 })
429 }
430
431 /// Group timestamped response times into fixed-width buckets and compute
432 /// per-bucket statistics.
433 pub fn bucket_by_time(data: &[(String, i64)], bucket_minutes: u64) -> Vec<LatencyBucket> {
434 if data.is_empty() || bucket_minutes == 0 {
435 return Vec::new();
436 }
437 let bucket_secs = bucket_minutes * 60;
438 let mut buckets: Vec<(i64, Vec<i64>)> = Vec::new();
439
440 for (ts, ms) in data {
441 let epoch = chrono::DateTime::parse_from_rfc3339(ts)
442 .map(|dt| dt.timestamp())
443 .unwrap_or(0);
444 let bucket_start = epoch - (epoch % bucket_secs as i64);
445 if let Some(last) = buckets.last_mut()
446 && last.0 == bucket_start
447 {
448 last.1.push(*ms);
449 continue;
450 }
451 buckets.push((bucket_start, vec![*ms]));
452 }
453
454 buckets
455 .into_iter()
456 .filter_map(|(start_epoch, times)| {
457 let stats = Self::from_times(&times)?;
458 let period_start = chrono::DateTime::from_timestamp(start_epoch, 0)
459 .map(|dt| dt.to_rfc3339())
460 .unwrap_or_default();
461 Some(LatencyBucket {
462 period_start,
463 min_ms: stats.min_ms,
464 max_ms: stats.max_ms,
465 avg_ms: stats.avg_ms,
466 p95_ms: stats.p95_ms,
467 sample_count: stats.sample_count,
468 })
469 })
470 .collect()
471 }
472 }
473
474 #[cfg(test)]
475 mod tests {
476 use super::*;
477
478 #[test]
479 fn health_status_display_roundtrip() {
480 for status in [
481 HealthStatus::Operational,
482 HealthStatus::Degraded,
483 HealthStatus::Error,
484 HealthStatus::Unreachable,
485 ] {
486 let s = status.to_string();
487 let parsed: HealthStatus = s.parse().unwrap();
488 assert_eq!(parsed, status);
489 }
490 }
491
492 #[test]
493 fn health_status_icons() {
494 assert_eq!(HealthStatus::Operational.icon(), "OK");
495 assert_eq!(HealthStatus::Degraded.icon(), "WARN");
496 assert_eq!(HealthStatus::Error.icon(), "ERR");
497 assert_eq!(HealthStatus::Unreachable.icon(), "DOWN");
498 }
499
500 #[test]
501 fn health_status_from_str_rejects_unknown() {
502 assert!("bogus".parse::<HealthStatus>().is_err());
503 }
504
505 #[test]
506 fn health_status_serde_roundtrip() {
507 let status = HealthStatus::Operational;
508 let json = serde_json::to_string(&status).unwrap();
509 assert_eq!(json, "\"operational\"");
510 let parsed: HealthStatus = serde_json::from_str(&json).unwrap();
511 assert_eq!(parsed, status);
512 }
513
514 // --- LatencyStats ---
515
516 #[test]
517 fn latency_stats_single_element() {
518 let stats = LatencyStats::from_times(&[100]).unwrap();
519 assert_eq!(stats.min_ms, 100);
520 assert_eq!(stats.max_ms, 100);
521 assert_eq!(stats.avg_ms, 100.0);
522 assert_eq!(stats.p95_ms, 100);
523 assert_eq!(stats.sample_count, 1);
524 }
525
526 #[test]
527 fn latency_stats_five_elements() {
528 let stats = LatencyStats::from_times(&[50, 100, 150, 200, 250]).unwrap();
529 assert_eq!(stats.min_ms, 50);
530 assert_eq!(stats.max_ms, 250);
531 assert_eq!(stats.avg_ms, 150.0);
532 assert_eq!(stats.p95_ms, 250);
533 assert_eq!(stats.sample_count, 5);
534 }
535
536 #[test]
537 fn latency_stats_hundred_elements() {
538 let times: Vec<i64> = (1..=100).collect();
539 let stats = LatencyStats::from_times(&times).unwrap();
540 assert_eq!(stats.min_ms, 1);
541 assert_eq!(stats.max_ms, 100);
542 assert_eq!(stats.p95_ms, 95);
543 assert_eq!(stats.sample_count, 100);
544 }
545
546 #[test]
547 fn latency_stats_empty() {
548 assert!(LatencyStats::from_times(&[]).is_none());
549 }
550
551 #[test]
552 fn latency_stats_p95_boundary() {
553 // 20 elements: p95 index = ceil(20 * 0.95) - 1 = 18 → value 19
554 let times: Vec<i64> = (1..=20).collect();
555 let stats = LatencyStats::from_times(&times).unwrap();
556 assert_eq!(stats.p95_ms, 19);
557 }
558
559 #[test]
560 fn latency_bucket_by_time_even_split() {
561 let data: Vec<(String, i64)> = vec![
562 ("2026-03-10T00:00:00+00:00".to_string(), 100),
563 ("2026-03-10T00:30:00+00:00".to_string(), 120),
564 ("2026-03-10T01:00:00+00:00".to_string(), 140),
565 ("2026-03-10T01:30:00+00:00".to_string(), 160),
566 ];
567 let buckets = LatencyStats::bucket_by_time(&data, 60);
568 assert_eq!(buckets.len(), 2);
569 assert_eq!(buckets[0].sample_count, 2);
570 assert_eq!(buckets[1].sample_count, 2);
571 }
572
573 #[test]
574 fn latency_bucket_by_time_single_bucket() {
575 let data: Vec<(String, i64)> = vec![
576 ("2026-03-10T00:01:00+00:00".to_string(), 100),
577 ("2026-03-10T00:02:00+00:00".to_string(), 200),
578 ];
579 let buckets = LatencyStats::bucket_by_time(&data, 60);
580 assert_eq!(buckets.len(), 1);
581 assert_eq!(buckets[0].sample_count, 2);
582 assert_eq!(buckets[0].avg_ms, 150.0);
583 }
584
585 #[test]
586 fn latency_bucket_by_time_empty() {
587 let buckets = LatencyStats::bucket_by_time(&[], 60);
588 assert!(buckets.is_empty());
589 }
590
591 #[test]
592 fn dns_check_result_serde_roundtrip() {
593 let result = DnsCheckResult {
594 target: "mnw".to_string(),
595 name: "makenot.work".to_string(),
596 record_type: DnsRecordType::A,
597 expected: vec!["5.78.144.244".to_string()],
598 actual: vec!["5.78.144.244".to_string()],
599 matches: true,
600 checked_at: "2026-03-15T00:00:00Z".to_string(),
601 error: None,
602 };
603 let json = serde_json::to_string(&result).unwrap();
604 let parsed: DnsCheckResult = serde_json::from_str(&json).unwrap();
605 assert_eq!(parsed.target, "mnw");
606 assert_eq!(parsed.name, "makenot.work");
607 assert_eq!(parsed.record_type, DnsRecordType::A);
608 assert!(parsed.matches);
609 assert!(parsed.error.is_none());
610 }
611
612 // --- AlertCategory ---
613
614 #[test]
615 fn alert_category_display_roundtrip() {
616 for category in [
617 AlertCategory::Health,
618 AlertCategory::Recovery,
619 AlertCategory::TlsExpiry,
620 AlertCategory::TlsError,
621 AlertCategory::TlsRecovery,
622 AlertCategory::PeerMissing,
623 AlertCategory::PeerRecovery,
624 AlertCategory::RouteFailure,
625 AlertCategory::RouteRecovery,
626 AlertCategory::DnsMismatch,
627 AlertCategory::DnsRecovery,
628 AlertCategory::WhoisExpiry,
629 AlertCategory::WhoisError,
630 AlertCategory::LatencyDrift,
631 AlertCategory::LatencyRecovery,
632 AlertCategory::TestDurationDrift,
633 AlertCategory::CorsFailure,
634 AlertCategory::CorsRecovery,
635 AlertCategory::MonitoringOffline,
636 AlertCategory::MonitoringRecovery,
637 ] {
638 let s = category.to_string();
639 let parsed: AlertCategory = s.parse().unwrap();
640 assert_eq!(parsed, category);
641 }
642 }
643
644 #[test]
645 fn alert_category_from_str_rejects_unknown() {
646 assert!("bogus".parse::<AlertCategory>().is_err());
647 }
648
649 // --- DnsRecordType ---
650
651 #[test]
652 fn dns_record_type_display_roundtrip() {
653 for rt in [
654 DnsRecordType::A,
655 DnsRecordType::Aaaa,
656 DnsRecordType::Cname,
657 DnsRecordType::Mx,
658 DnsRecordType::Txt,
659 ] {
660 let s = rt.to_string();
661 let parsed: DnsRecordType = s.parse().unwrap();
662 assert_eq!(parsed, rt);
663 }
664 }
665
666 #[test]
667 fn dns_record_type_from_str_rejects_unknown() {
668 assert!("SRV".parse::<DnsRecordType>().is_err());
669 }
670
671 #[test]
672 fn whois_result_serde_roundtrip() {
673 let result = WhoisResult {
674 target: "mnw".to_string(),
675 domain: "makenot.work".to_string(),
676 registrar: Some("Namecheap".to_string()),
677 expiry_date: Some("2027-01-15T00:00:00Z".to_string()),
678 days_remaining: Some(306),
679 nameservers: vec!["ns1.example.com".to_string()],
680 checked_at: "2026-03-15T00:00:00Z".to_string(),
681 error: None,
682 };
683 let json = serde_json::to_string(&result).unwrap();
684 let parsed: WhoisResult = serde_json::from_str(&json).unwrap();
685 assert_eq!(parsed.domain, "makenot.work");
686 assert_eq!(parsed.registrar.as_deref(), Some("Namecheap"));
687 assert_eq!(parsed.days_remaining, Some(306));
688 }
689
690 #[test]
691 fn latency_stats_serde_roundtrip() {
692 let stats = LatencyStats::from_times(&[50, 100, 150]).unwrap();
693 let json = serde_json::to_string(&stats).unwrap();
694 let parsed: LatencyStats = serde_json::from_str(&json).unwrap();
695 assert_eq!(parsed.min_ms, stats.min_ms);
696 assert_eq!(parsed.max_ms, stats.max_ms);
697 assert_eq!(parsed.p95_ms, stats.p95_ms);
698 assert_eq!(parsed.sample_count, stats.sample_count);
699 }
700 }
701