From 9ccf6f5cbe0d753ecd565a2443f3352c580dc93f Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Fri, 31 Jul 2026 00:48:32 +0000 Subject: [PATCH] test(sab): add SABnzbd 5.0.4 conformance goldens (#50) --- crates/nzb-web/src/lib.rs | 2 + crates/nzb-web/src/sabnzbd_compat.rs | 322 +++++++++++++++++- .../tests/fixtures/sabnzbd-5.0.4/README.md | 24 ++ .../fixtures/sabnzbd-5.0.4/fullstatus.json | 60 ++++ .../tests/fixtures/sabnzbd-5.0.4/history.json | 48 +++ .../tests/fixtures/sabnzbd-5.0.4/queue.json | 60 ++++ .../tests/fixtures/sabnzbd-5.0.4/version.json | 3 + crates/nzb-web/tests/support/mod.rs | 1 + crates/nzb-web/tests/support/sab_contract.rs | 99 ++++++ 9 files changed, 601 insertions(+), 18 deletions(-) create mode 100644 crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/README.md create mode 100644 crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/fullstatus.json create mode 100644 crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/history.json create mode 100644 crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/queue.json create mode 100644 crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/version.json create mode 100644 crates/nzb-web/tests/support/mod.rs create mode 100644 crates/nzb-web/tests/support/sab_contract.rs diff --git a/crates/nzb-web/src/lib.rs b/crates/nzb-web/src/lib.rs index e180231..8c05bea 100644 --- a/crates/nzb-web/src/lib.rs +++ b/crates/nzb-web/src/lib.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + pub use nzb_decode; pub use nzb_postproc; pub use nzb_postproc::nzb_core; diff --git a/crates/nzb-web/src/sabnzbd_compat.rs b/crates/nzb-web/src/sabnzbd_compat.rs index fa0f56b..399e1dc 100644 --- a/crates/nzb-web/src/sabnzbd_compat.rs +++ b/crates/nzb-web/src/sabnzbd_compat.rs @@ -17,6 +17,11 @@ use crate::nzb_core::nzb_parser; use crate::error::ApiError; use crate::state::AppState; +/// SABnzbd release whose public response contract this compatibility layer +/// targets. Keep this in sync with the conformance fixtures under +/// `tests/fixtures/sabnzbd-*`. +const SABNZBD_COMPAT_VERSION: &str = "5.0.4"; + /// Arr-compatible API request -- all parameters come as query strings. #[derive(Deserialize, Default)] pub struct SabApiRequest { @@ -362,7 +367,7 @@ pub async fn h_sabnzbd_api_post( fn dispatch_mode(state: &AppState, mode: &str, req: &SabApiRequest) -> Json { match mode { "version" => Json(serde_json::json!({ - "version": "4.3.3" + "version": SABNZBD_COMPAT_VERSION })), "queue" => handle_queue(state, req), @@ -386,16 +391,7 @@ fn dispatch_mode(state: &AppState, mode: &str, req: &SabApiRequest) -> Json handle_priority(state, req), - "fullstatus" | "server_stats" => { - let qm = &state.queue_manager; - Json(serde_json::json!({ - "status": { - "version": "4.3.3", - "paused": qm.is_paused(), - "speed": format!("{}", qm.get_speed()), - } - })) - } + "fullstatus" | "server_stats" => handle_fullstatus(state), "pause" => handle_pause(state, req), @@ -412,6 +408,84 @@ fn dispatch_mode(state: &AppState, mode: &str, req: &SabApiRequest) -> Json Json { + let config = state.config(); + let general = &config.general; + let qm = &state.queue_manager; + let speed_limit = qm.get_speed_limit(); + let pause_int = qm.pause_remaining_secs().unwrap_or(0).max(0).to_string(); + + Json(serde_json::json!({ + "status": { + "active_lang": "en", + "active_socks5_proxy": serde_json::Value::Null, + "apikey": general.api_key.as_deref().unwrap_or(""), + "cache_art": "0", + "cache_size": format_size_human(general.cache_size), + "color_scheme": "Auto", + "completedir": general.complete_dir.to_string_lossy(), + "completedirspeed": 0, + "configfn": state.config_path.to_string_lossy(), + "confighelpuri": "https://sabnzbd.org/wiki/configuration/5.0/", + "delayed_assembler": 0, + "diskspace1": "0.00", + "diskspace1_norm": "0 B", + "diskspace2": "0.00", + "diskspace2_norm": "0 B", + "diskspacetotal1": "0.00", + "diskspacetotal2": "0.00", + "dnslookup": false, + "downloaddir": general.incomplete_dir.to_string_lossy(), + "downloaddirspeed": 0, + "finishaction": serde_json::Value::Null, + "folders": Vec::::new(), + "have_quota": false, + "have_warnings": "0", + "internetbandwidth": 0, + "ipv6": serde_json::Value::Null, + "left_quota": "0 B", + "loadavg": "", + "localipv4": serde_json::Value::Null, + "logfile": general + .log_file + .as_ref() + .map_or_else(String::new, |path| path.to_string_lossy().into_owned()), + "loglevel": &general.log_level, + "macos": cfg!(target_os = "macos"), + "my_home": general.data_dir.to_string_lossy(), + "my_lcldata": general.data_dir.to_string_lossy(), + "new_rel_url": serde_json::Value::Null, + "new_release": serde_json::Value::Null, + "pause_int": pause_int, + "paused": qm.is_paused(), + "paused_all": false, + "pid": std::process::id(), + "power_options": false, + "pp_pause_event": false, + "publicipv4": serde_json::Value::Null, + "pystone": 0, + "quota": "0 B", + "rtl": false, + "servers": Vec::::new(), + "speedlimit": if speed_limit == 0 { "0" } else { "100" }, + "speedlimit_abs": speed_limit.to_string(), + "uptime": "0m", + "url_base": "", + "version": SABNZBD_COMPAT_VERSION, + "warnings": Vec::::new(), + "webdir": "", + "weblogfile": serde_json::Value::Null, + "windows": cfg!(target_os = "windows"), + } + })) +} + // --------------------------------------------------------------------------- // Mode handlers // --------------------------------------------------------------------------- @@ -525,11 +599,13 @@ fn build_queue_response( serde_json::json!({ "queue": { - "version": env!("CARGO_PKG_VERSION"), + "version": SABNZBD_COMPAT_VERSION, "status": queue_status(paused, speed_bps), "paused": paused, "pause_int": "0", - "paused_all": paused, + // SABnzbd uses `paused_all` for a distinct scheduler condition; + // the ordinary global pause endpoint only sets `paused`. + "paused_all": false, "speedlimit": "0", "speedlimit_abs": speed_limit_bps.to_string(), "speed": format_speed(speed_bps), @@ -592,8 +668,7 @@ fn queue_status(paused: bool, speed_bps: u64) -> &'static str { } fn queue_totals_include(job: &NzbJob) -> bool { - job.priority == Priority::Force - || !matches!(job.status, JobStatus::Paused | JobStatus::Verifying) + !matches!(job.status, JobStatus::Completed | JobStatus::Failed) } /// Handle mode=queue&name=delete&value=nzo_ID (SABnzbd queue delete) @@ -750,7 +825,7 @@ fn build_history_response( "noofslots": noofslots, "ppslots": ppslots, "last_history_update": history_update, - "version": "4.3.3" + "version": SABNZBD_COMPAT_VERSION } }) } @@ -1221,7 +1296,15 @@ impl SabQueueSlot { } else { job.category.clone() }, - status: sab_queue_status(job.status).into(), + // RustNZB marks queued jobs Paused when the global gate is + // applied. SABnzbd preserves their queue-facing `Queued` state + // while reporting the gate through the envelope's `paused` key. + status: if globally_paused && job.status == JobStatus::Paused { + "Queued" + } else { + sab_queue_status(job.status) + } + .into(), priority: sab_priority_name(job.priority).into(), mb: format!("{mb:.2}"), mbleft: format!("{mbleft:.2}"), @@ -1482,6 +1565,77 @@ fn format_timeleft(bytes_left: u64, speed_bps: u64) -> String { #[cfg(test)] mod tests { use super::*; + use std::path::Path; + + use arc_swap::ArcSwap; + use chrono::TimeZone; + + use crate::auth::{CredentialStore, TokenStore}; + use crate::log_buffer::LogBuffer; + use crate::nzb_core::config::AppConfig; + use crate::nzb_core::db::Database; + use crate::queue_manager::QueueManager; + + mod sab_contract { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/support/sab_contract.rs" + )); + } + + const VERSION_GOLDEN: &str = include_str!("../tests/fixtures/sabnzbd-5.0.4/version.json"); + const QUEUE_GOLDEN: &str = include_str!("../tests/fixtures/sabnzbd-5.0.4/queue.json"); + const HISTORY_GOLDEN: &str = include_str!("../tests/fixtures/sabnzbd-5.0.4/history.json"); + const FULLSTATUS_GOLDEN: &str = include_str!("../tests/fixtures/sabnzbd-5.0.4/fullstatus.json"); + + struct TestState { + state: AppState, + _tempdir: tempfile::TempDir, + } + + fn test_state() -> TestState { + let tempdir = tempfile::tempdir().expect("create SAB conformance tempdir"); + let mut config = AppConfig::default(); + config.general.api_key = Some("contract-api-key".into()); + config.general.data_dir = tempdir.path().join("data"); + config.general.incomplete_dir = tempdir.path().join("incomplete"); + config.general.complete_dir = tempdir.path().join("complete"); + config.general.cache_size = 512 * 1024 * 1024; + config.general.speed_limit_bps = 8 * 1024 * 1024; + + let log_buffer = LogBuffer::default(); + let queue_manager = QueueManager::new( + Vec::new(), + Database::open_memory().expect("open in-memory conformance database"), + config.general.incomplete_dir.clone(), + config.general.complete_dir.clone(), + log_buffer.clone(), + 1, + Vec::new(), + 0, + config.general.speed_limit_bps, + false, + 5, + false, + false, + 100.0, + 30, + ); + let config_path = tempdir.path().join("sab-conformance.toml"); + let state = AppState::new( + Arc::new(ArcSwap::from_pointee(config)), + config_path, + queue_manager, + log_buffer, + Arc::new(TokenStore::new()), + Arc::new(CredentialStore::new(tempdir.path().to_path_buf())), + ); + + TestState { + state, + _tempdir: tempdir, + } + } fn queue_job(id: &str, name: &str, category: &str, status: JobStatus) -> NzbJob { NzbJob { @@ -1672,7 +1826,7 @@ mod tests { let response = build_queue_response(&jobs, false, 0, 0, &req); let queue = &response["queue"]; - assert_eq!(queue["noofslots_total"], 3); + assert_eq!(queue["noofslots_total"], 4); assert_eq!(queue["noofslots"], 3); assert_eq!(queue["start"], 1); assert_eq!(queue["limit"], 1); @@ -1862,4 +2016,136 @@ mod tests { serde_json::json!({ "history": false }) ); } + + #[tokio::test] + async fn version_matches_sabnzbd_golden_contract() { + let state = test_state(); + let expected = sab_contract::golden(VERSION_GOLDEN); + let actual = dispatch_mode(&state.state, "version", &SabApiRequest::default()).0; + + sab_contract::assert_matches_golden(actual, &expected); + } + + #[tokio::test] + async fn fullstatus_matches_sabnzbd_golden_contract() { + let state = test_state(); + let expected = sab_contract::golden(FULLSTATUS_GOLDEN); + let actual = dispatch_mode(&state.state, "fullstatus", &SabApiRequest::default()).0; + + sab_contract::assert_matches_golden(actual, &expected); + } + + #[tokio::test] + async fn queue_matches_sabnzbd_golden_contract() { + let state = test_state(); + state.state.queue_manager.pause_all(); + let added_at = chrono::Utc + .timestamp_opt(1_700_000_000, 0) + .single() + .expect("valid fixture time"); + let job = NzbJob { + id: "contract-queue-job".into(), + name: "SAB contract fixture".into(), + category: "tv".into(), + status: JobStatus::Queued, + priority: Priority::Normal, + total_bytes: 1_048_576, + downloaded_bytes: 0, + file_count: 1, + files_completed: 0, + article_count: 1, + articles_downloaded: 0, + articles_failed: 0, + added_at, + completed_at: None, + work_dir: state + .state + .config() + .general + .incomplete_dir + .join("contract-queue-job"), + output_dir: state + .state + .config() + .general + .complete_dir + .join("contract-queue-job"), + password: None, + error_message: None, + speed_bps: 0, + server_stats: Vec::new(), + files: Vec::new(), + }; + state + .state + .queue_manager + .add_job(job, None) + .expect("add queue fixture"); + let request = SabApiRequest { + limit: Some(1), + ..SabApiRequest::default() + }; + let actual = dispatch_mode(&state.state, "queue", &request).0; + + sab_contract::assert_matches_golden(actual, &sab_contract::golden(QUEUE_GOLDEN)); + } + + #[tokio::test] + async fn history_matches_sabnzbd_golden_contract() { + let state = test_state(); + let added_at = chrono::Utc + .timestamp_opt(1_700_000_000, 0) + .single() + .expect("valid fixture time"); + let entry = HistoryEntry { + id: "contract-history-job".into(), + name: "SAB history fixture".into(), + category: "tv".into(), + status: JobStatus::Completed, + total_bytes: 1_048_576, + downloaded_bytes: 1_048_576, + added_at, + completed_at: added_at + chrono::Duration::seconds(10), + download_time_secs: Some(5.0), + output_dir: state + .state + .config() + .general + .complete_dir + .join("contract-history-job"), + stages: Vec::new(), + error_message: None, + server_stats: Vec::new(), + nzb_data: None, + }; + state.state.queue_manager.with_db(|database| { + database + .history_insert(&entry) + .expect("insert history fixture") + }); + let request = SabApiRequest { + limit: Some(1), + ..SabApiRequest::default() + }; + let actual = dispatch_mode(&state.state, "history", &request).0; + + sab_contract::assert_matches_golden(actual, &sab_contract::golden(HISTORY_GOLDEN)); + } + + #[test] + fn checked_in_sabnzbd_goldens_are_valid_json() { + let fixture_dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sabnzbd-5.0.4"); + + for name in [ + "queue.json", + "history.json", + "fullstatus.json", + "version.json", + ] { + let contents = std::fs::read_to_string(fixture_dir.join(name)) + .unwrap_or_else(|error| panic!("read {name}: {error}")); + sab_contract::golden(&contents); + } + } } diff --git a/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/README.md b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/README.md new file mode 100644 index 0000000..42c3680 --- /dev/null +++ b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/README.md @@ -0,0 +1,24 @@ +# SABnzbd 5.0.4 API goldens + +These normalized JSON responses define RustNZB's SAB-compatible response +contract. They were captured from the supported SABnzbd 5.0.4 release +(`128e0d03d7cc61af7e73b18376b880219fbc3596`) using the LinuxServer image: + +```text +lscr.io/linuxserver/sabnzbd:5.0.4 +sha256:302be8972d4627222a0701634f2f9025826d760d856414aa6e67c0a66833e5be +``` + +The key sets and types were cross-checked against SABnzbd's own strict Tavern +fixtures at tag `5.0.4`: + +- `tests/data/tavern/api_queue_empty.yaml` +- `tests/data/tavern/api_queue_format.yaml` +- `tests/data/tavern/api_history_empty.yaml` +- `tests/data/tavern/api_history_format.yaml` +- `tests/data/tavern/api_version.yaml` +- `sabnzbd/api.py::build_status` (there is no upstream fullstatus Tavern case) + +`$type:*` markers replace only environment- or time-dependent values. The +conformance helper verifies each marker's original JSON type before +normalization, then compares the full document so extra and missing keys fail. diff --git a/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/fullstatus.json b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/fullstatus.json new file mode 100644 index 0000000..9022669 --- /dev/null +++ b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/fullstatus.json @@ -0,0 +1,60 @@ +{ + "status": { + "active_lang": "en", + "active_socks5_proxy": "$type:any", + "apikey": "$type:string", + "cache_art": "$type:string", + "cache_size": "$type:string", + "color_scheme": "$type:string", + "completedir": "$type:string", + "completedirspeed": "$type:number", + "configfn": "$type:string", + "confighelpuri": "$type:string", + "delayed_assembler": "$type:integer", + "diskspace1": "$type:string", + "diskspace1_norm": "$type:string", + "diskspace2": "$type:string", + "diskspace2_norm": "$type:string", + "diskspacetotal1": "$type:string", + "diskspacetotal2": "$type:string", + "dnslookup": "$type:boolean", + "downloaddir": "$type:string", + "downloaddirspeed": "$type:number", + "finishaction": "$type:any", + "folders": "$type:array", + "have_quota": "$type:boolean", + "have_warnings": "$type:string", + "internetbandwidth": "$type:number", + "ipv6": "$type:any", + "left_quota": "$type:string", + "loadavg": "$type:string", + "localipv4": "$type:any", + "logfile": "$type:string", + "loglevel": "$type:string", + "macos": "$type:boolean", + "my_home": "$type:string", + "my_lcldata": "$type:string", + "new_rel_url": "$type:any", + "new_release": "$type:any", + "pause_int": "$type:string", + "paused": "$type:boolean", + "paused_all": "$type:boolean", + "pid": "$type:integer", + "power_options": "$type:boolean", + "pp_pause_event": "$type:boolean", + "publicipv4": "$type:any", + "pystone": "$type:number", + "quota": "$type:string", + "rtl": "$type:boolean", + "servers": "$type:array", + "speedlimit": "$type:string", + "speedlimit_abs": "$type:string", + "uptime": "$type:string", + "url_base": "$type:string", + "version": "5.0.4", + "warnings": "$type:array", + "webdir": "$type:string", + "weblogfile": "$type:any", + "windows": "$type:boolean" + } +} diff --git a/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/history.json b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/history.json new file mode 100644 index 0000000..734d6cf --- /dev/null +++ b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/history.json @@ -0,0 +1,48 @@ +{ + "history": { + "day_size": "$type:string", + "last_history_update": "$type:integer", + "month_size": "$type:string", + "noofslots": 1, + "ppslots": 0, + "slots": [ + { + "action_line": "$type:any", + "archive": false, + "bytes": 1048576, + "category": "tv", + "completed": "$type:integer", + "completeness": "$type:any", + "download_time": 5, + "downloaded": 1048576, + "duplicate_key": "$type:any", + "fail_message": "", + "loaded": false, + "md5sum": "$type:string", + "meta": "$type:any", + "name": "SAB history fixture", + "nzb_name": "SAB history fixture.nzb", + "nzo_id": "$type:string", + "password": "", + "path": "$type:string", + "postproc_time": "$type:integer", + "pp": "D", + "report": "$type:any", + "retry": false, + "script": "$type:string", + "script_line": "$type:string", + "series": "$type:any", + "size": "$type:string", + "stage_log": [], + "status": "Completed", + "storage": "$type:string", + "time_added": "$type:integer", + "url": "", + "url_info": "" + } + ], + "total_size": "$type:string", + "version": "5.0.4", + "week_size": "$type:string" + } +} diff --git a/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/queue.json b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/queue.json new file mode 100644 index 0000000..926bee2 --- /dev/null +++ b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/queue.json @@ -0,0 +1,60 @@ +{ + "queue": { + "cache_art": "$type:string", + "cache_size": "$type:string", + "diskspace1": "$type:string", + "diskspace1_norm": "$type:string", + "diskspace2": "$type:string", + "diskspace2_norm": "$type:string", + "diskspacetotal1": "$type:string", + "diskspacetotal2": "$type:string", + "finish": 1, + "finishaction": "$type:any", + "have_quota": "$type:boolean", + "have_warnings": "$type:string", + "kbpersec": "$type:string", + "left_quota": "$type:string", + "limit": 1, + "mb": "$type:string", + "mbleft": "$type:string", + "noofslots": 1, + "noofslots_total": 1, + "pause_int": "$type:string", + "paused": true, + "paused_all": false, + "quota": "$type:string", + "size": "$type:string", + "sizeleft": "$type:string", + "slots": [ + { + "avg_age": "$type:string", + "cat": "tv", + "direct_unpack": "$type:any", + "filename": "SAB contract fixture", + "index": 0, + "labels": [], + "mb": "$type:string", + "mbleft": "$type:string", + "mbmissing": "$type:string", + "nzo_id": "$type:string", + "password": "", + "percentage": "$type:string", + "priority": "Normal", + "script": "$type:string", + "size": "$type:string", + "sizeleft": "$type:string", + "status": "Queued", + "time_added": "$type:integer", + "timeleft": "$type:string", + "unpackopts": "$type:string" + } + ], + "speed": "$type:string", + "speedlimit": "$type:string", + "speedlimit_abs": "$type:string", + "start": 0, + "status": "Paused", + "timeleft": "$type:string", + "version": "5.0.4" + } +} diff --git a/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/version.json b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/version.json new file mode 100644 index 0000000..8747569 --- /dev/null +++ b/crates/nzb-web/tests/fixtures/sabnzbd-5.0.4/version.json @@ -0,0 +1,3 @@ +{ + "version": "5.0.4" +} diff --git a/crates/nzb-web/tests/support/mod.rs b/crates/nzb-web/tests/support/mod.rs new file mode 100644 index 0000000..f04827a --- /dev/null +++ b/crates/nzb-web/tests/support/mod.rs @@ -0,0 +1 @@ +pub mod sab_contract; diff --git a/crates/nzb-web/tests/support/sab_contract.rs b/crates/nzb-web/tests/support/sab_contract.rs new file mode 100644 index 0000000..a757e1b --- /dev/null +++ b/crates/nzb-web/tests/support/sab_contract.rs @@ -0,0 +1,99 @@ +use serde_json::Value; + +/// Load a checked-in SABnzbd golden response. +pub fn golden(source: &str) -> Value { + serde_json::from_str(source).expect("golden SABnzbd fixture must be valid JSON") +} + +/// Normalize dynamic response fields and assert their original JSON types. +/// +/// Golden fixtures use `$type:*` markers only for values that cannot be made +/// deterministic (timestamps, rates, paths, host measurements, and generated +/// IDs). The marker is copied into the normalized response after its type has +/// been checked, leaving an ordinary equality assertion to report missing, +/// extra, or semantically different fields with a complete JSON diff. +pub fn normalize_dynamic_fields(actual: &mut Value, expected: &Value) { + normalize_at(actual, expected, "$"); +} + +pub fn assert_matches_golden(mut actual: Value, expected: &Value) { + normalize_dynamic_fields(&mut actual, expected); + assert_eq!(actual, *expected, "normalized SABnzbd response mismatch"); +} + +fn normalize_at(actual: &mut Value, expected: &Value, path: &str) { + if let Some(marker) = expected.as_str().filter(|value| value.starts_with("$type:")) { + assert_marker(actual, marker, path); + *actual = expected.clone(); + return; + } + + match (actual, expected) { + (Value::Object(actual), Value::Object(expected)) => { + for (key, expected_value) in expected { + let field_path = format!("{path}.{key}"); + let actual_value = actual + .get_mut(key) + .unwrap_or_else(|| panic!("missing SABnzbd response field `{field_path}`")); + normalize_at(actual_value, expected_value, &field_path); + } + } + (Value::Array(actual), Value::Array(expected)) => { + assert_eq!( + actual.len(), + expected.len(), + "array length differs at `{path}`" + ); + for (index, (actual_value, expected_value)) in + actual.iter_mut().zip(expected).enumerate() + { + normalize_at(actual_value, expected_value, &format!("{path}[{index}]")); + } + } + _ => {} + } +} + +fn assert_marker(actual: &Value, marker: &str, path: &str) { + let valid = match marker { + "$type:any" => true, + "$type:array" => actual.is_array(), + "$type:boolean" => actual.is_boolean(), + "$type:integer" => actual.as_i64().is_some() || actual.as_u64().is_some(), + "$type:null" => actual.is_null(), + "$type:null-or-string" => actual.is_null() || actual.is_string(), + "$type:number" => actual.is_number(), + "$type:object" => actual.is_object(), + "$type:string" => actual.is_string(), + unsupported => panic!("unsupported golden marker `{unsupported}` at `{path}`"), + }; + + assert!( + valid, + "SABnzbd response type mismatch at `{path}`: expected `{marker}`, got {actual}" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalizes_only_marked_dynamic_fields() { + let expected = json!({"fixed": "Idle", "rate": "$type:string", "count": "$type:integer"}); + let mut actual = json!({"fixed": "Idle", "rate": "12.3 MB/s", "count": 4}); + + normalize_dynamic_fields(&mut actual, &expected); + + assert_eq!(actual, expected); + } + + #[test] + #[should_panic(expected = "$.rate")] + fn reports_the_path_of_a_dynamic_type_mismatch() { + let expected = json!({"rate": "$type:string"}); + let mut actual = json!({"rate": 12}); + normalize_dynamic_fields(&mut actual, &expected); + } +}