Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6376fb5
fix: support mode=addurl over GET so category is honored (#65)
thedancingdeveloper Aug 11, 2026
50c2753
fix: correct SABnzbd priority numeric mapping (#71)
thedancingdeveloper Aug 11, 2026
ebb2ec2
fix: route mode=queue&name=priority/rename/purge (#72)
thedancingdeveloper Aug 11, 2026
50da874
fix: get_cats reports "*" default-category sentinel (#73)
thedancingdeveloper Aug 11, 2026
58209ad
fix: implement mode=get_scripts (#74)
thedancingdeveloper Aug 11, 2026
6fdb404
fix: queue/history delete support multi-ID value and del_files (#75)
thedancingdeveloper Aug 11, 2026
b319973
fix: change_cat accepts multiple comma-separated nzo_ids (#76)
thedancingdeveloper Aug 11, 2026
ef848ef
Merge branch 'fix/72-queue-subcommand-routing' into fix/77-e2e-sab-co…
thedancingdeveloper Aug 11, 2026
86aed0d
Merge branch 'fix/73-get-cats-sentinel' into fix/77-e2e-sab-compliance
thedancingdeveloper Aug 11, 2026
4121b6b
Merge branch 'fix/74-get-scripts' into fix/77-e2e-sab-compliance
thedancingdeveloper Aug 11, 2026
a8117e4
Merge branch 'fix/75-multi-id-delete' into fix/77-e2e-sab-compliance
thedancingdeveloper Aug 11, 2026
8233ca2
Merge branch 'fix/76-change-cat-multi-id' into fix/77-e2e-sab-compliance
thedancingdeveloper Aug 11, 2026
f02f9ed
fix: resolve full job id before set_job_priority; add e2e SAB complia…
thedancingdeveloper Aug 11, 2026
d244b6b
Merge branch 'fix/addurl-get-category' into fix/77-e2e-sab-compliance
thedancingdeveloper Aug 11, 2026
5b6ee0d
Merge remote-tracking branch 'origin/main' into fix/77-e2e-sab-compli…
thedancingdeveloper Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
383 changes: 383 additions & 0 deletions apps/rustnzb/tests/sab_compliance_e2e.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,383 @@
//! Durable end-to-end coverage for RustNZB's SABnzbd-compatible API
//! (`/sabnzbd/api`), exercised over real HTTP against a live server instance
//! -- not just the in-process handler-function tests in
//! `nzb-web/src/sabnzbd_compat.rs`.
//!
//! This suite exists because rustnzb's SAB compatibility layer drifted from
//! the real protocol several times without any test catching it (see
//! TheDancingDeveloper-org/rustnzb#65, #71-#76): a real endpoint
//! (`mode=queue&name=priority`) was entirely unreachable, numeric priority
//! codes were shifted by one, `get_cats`/`get_scripts` didn't match what a
//! compliant client actually receives, and `addurl` didn't work over GET at
//! all. Each assertion below is paired with the exact upstream SABnzbd
//! (`sabnzbd/sabnzbd@5.1.x`) source it was verified against, so a future
//! change that reintroduces one of these regressions fails immediately
//! instead of silently shipping.

mod support;

use support::{sample_nzb_bytes, start_test_server};

/// Serves `body` once over a raw TCP listener, returning the URL to fetch it
/// from. Used to exercise `mode=addurl` (a real HTTP GET fetch) without
/// depending on an external network.
async fn spawn_nzb_server(body: Vec<u8>) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};

let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral test server");
let addr = listener.local_addr().expect("test server local addr");

tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept test connection");
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let mut response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/x-nzb\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
response.extend_from_slice(&body);
let _ = socket.write_all(&response).await;
let _ = socket.shutdown().await;
});

format!("http://{addr}/test.nzb")
}

/// `mode=version` -- baseline connectivity check every SAB-compatible client
/// performs first.
#[tokio::test]
async fn version_reports_a_string() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();

let response: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=version", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();

assert!(response["version"].as_str().is_some());
}

/// `mode=addfile` (POST multipart) must apply `cat`/`priority`, and the
/// resulting queue slot must report SABnzbd's real priority vocabulary
/// ("High", not "Normal") -- regression coverage for the numeric mapping fix
/// (rustnzb#71) verified against `sabnzbd/constants.py: HIGH_PRIORITY = 1`.
#[tokio::test]
async fn addfile_applies_category_and_priority_and_reports_them_in_queue() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();

let form = reqwest::multipart::Form::new()
.text("mode", "addfile")
.text("cat", "tv")
.text("priority", "1")
.part(
"name",
reqwest::multipart::Part::bytes(sample_nzb_bytes())
.file_name("sample.nzb")
.mime_str("application/x-nzb")
.unwrap(),
);

let add_response: serde_json::Value = client
.post(format!("{}/sabnzbd/api", app.base_url))
.multipart(form)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(add_response["status"], serde_json::json!(true), "resp={add_response:?}");
let nzo_id = add_response["nzo_ids"][0].as_str().unwrap().to_string();

let queue: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=queue", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let slot = queue["queue"]["slots"]
.as_array()
.unwrap()
.iter()
.find(|slot| slot["nzo_id"] == nzo_id)
.expect("added job present in queue");
assert_eq!(slot["cat"], "tv");
// sabnzbd/constants.py: HIGH_PRIORITY = 1 -> "High", not "Normal".
assert_eq!(slot["priority"], "High");
}

/// `mode=addurl` fetches a remote NZB and has no file body to upload, so
/// real SABnzbd (and clients like NZB360/Sonarr/Radarr) issue it as a plain
/// GET. Regression coverage for rustnzb#65/PR#70: GET requests used to fall
/// through to "Unknown mode" and silently drop `cat`.
#[tokio::test]
async fn addurl_over_get_fetches_and_applies_category() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();
let nzb_url = spawn_nzb_server(sample_nzb_bytes()).await;

let add_response: serde_json::Value = client
.get(format!(
"{}/sabnzbd/api?mode=addurl&name={}&cat=movies",
app.base_url, nzb_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(add_response["status"], serde_json::json!(true), "resp={add_response:?}");
let nzo_id = add_response["nzo_ids"][0].as_str().unwrap().to_string();

let queue: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=queue", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let slot = queue["queue"]["slots"]
.as_array()
.unwrap()
.iter()
.find(|slot| slot["nzo_id"] == nzo_id)
.expect("URL-added job present in queue");
assert_eq!(slot["cat"], "movies");
}

/// `mode=get_cats` must report the default category as the literal `"*"`
/// sentinel, matching `sabnzbd/api.py::list_cats(default=False)` -- not a
/// display name like "Default". Regression coverage for rustnzb#73.
#[tokio::test]
async fn get_cats_reports_sabnzbd_default_sentinel() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();

let response: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=get_cats", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();

assert_eq!(response["categories"], serde_json::json!(["*"]));
}

/// `mode=get_scripts` is a real top-level SABnzbd mode
/// (`sabnzbd/api.py::_api_table["get_scripts"]`) that used to fall through
/// to "Unknown mode". Regression coverage for rustnzb#74.
#[tokio::test]
async fn get_scripts_reports_none() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();

let response: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=get_scripts", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();

assert_eq!(response["scripts"], serde_json::json!(["None"]));
}

async fn add_queued_job(app: &support::TestApp, client: &reqwest::Client) -> String {
let form = reqwest::multipart::Form::new()
.text("mode", "addfile")
.part(
"name",
reqwest::multipart::Part::bytes(sample_nzb_bytes())
.file_name("sample.nzb")
.mime_str("application/x-nzb")
.unwrap(),
);
let add_response: serde_json::Value = client
.post(format!("{}/sabnzbd/api", app.base_url))
.multipart(form)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(add_response["status"], serde_json::json!(true));
add_response["nzo_ids"][0].as_str().unwrap().to_string()
}

/// Real SABnzbd has no top-level `mode=priority`; priority changes are a
/// sub-command of `mode=queue` (`sabnzbd/api.py::_api_queue_table["priority"]`).
/// Regression coverage for rustnzb#72: this route used to silently fall
/// through to a plain queue listing instead of applying the change.
#[tokio::test]
async fn queue_priority_subcommand_changes_priority_over_http() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();
let nzo_id = add_queued_job(&app, &client).await;

let response: serde_json::Value = client
.get(format!(
"{}/sabnzbd/api?mode=queue&name=priority&value={nzo_id}&value2=2",
app.base_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["status"], serde_json::json!(true));

let queue: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=queue", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let slot = queue["queue"]["slots"]
.as_array()
.unwrap()
.iter()
.find(|slot| slot["nzo_id"] == nzo_id)
.expect("job present in queue");
// sabnzbd/constants.py: FORCE_PRIORITY = 2 -> "Force".
assert_eq!(slot["priority"], "Force");
}

/// Real SABnzbd's rename is also a `mode=queue` sub-command
/// (`_api_queue_table["rename"]`). Regression coverage for rustnzb#72.
#[tokio::test]
async fn queue_rename_subcommand_renames_over_http() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();
let nzo_id = add_queued_job(&app, &client).await;

let response: serde_json::Value = client
.get(format!(
"{}/sabnzbd/api?mode=queue&name=rename&value={nzo_id}&value2=Renamed%20Job",
app.base_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["status"], serde_json::json!(true));

let queue: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=queue", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let slot = queue["queue"]["slots"]
.as_array()
.unwrap()
.iter()
.find(|slot| slot["nzo_id"] == nzo_id)
.expect("job present in queue");
assert_eq!(slot["filename"], "Renamed Job");
}

/// `mode=change_cat` must accept a comma-separated `value` list, applying
/// the category to every matching job in one call
/// (`sabnzbd/api.py::_api_change_cat` -> `clean_comma_separated_list`).
/// Regression coverage for rustnzb#76.
#[tokio::test]
async fn change_cat_applies_to_multiple_jobs_over_http() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();
let first = add_queued_job(&app, &client).await;
let second = add_queued_job(&app, &client).await;

let response: serde_json::Value = client
.get(format!(
"{}/sabnzbd/api?mode=change_cat&value={first},{second}&value2=movies",
app.base_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["status"], serde_json::json!(true));

let queue: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=queue", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let slots = queue["queue"]["slots"].as_array().unwrap();
for nzo_id in [&first, &second] {
let slot = slots
.iter()
.find(|slot| slot["nzo_id"] == *nzo_id)
.expect("job present in queue");
assert_eq!(slot["cat"], "movies");
}
}

/// `mode=queue&name=delete` must accept a comma-separated `value` list,
/// removing every matching job in one call
/// (`sabnzbd/api.py::_api_queue_delete` -> `clean_comma_separated_list`).
/// Regression coverage for rustnzb#75.
#[tokio::test]
async fn queue_delete_removes_multiple_jobs_over_http() {
let app = start_test_server(Vec::new()).await;
let client = reqwest::Client::new();
let first = add_queued_job(&app, &client).await;
let second = add_queued_job(&app, &client).await;

let response: serde_json::Value = client
.get(format!(
"{}/sabnzbd/api?mode=queue&name=delete&value={first},{second}",
app.base_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["status"], serde_json::json!(true));

let queue: serde_json::Value = client
.get(format!("{}/sabnzbd/api?mode=queue", app.base_url))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let slots = queue["queue"]["slots"].as_array().unwrap();
assert!(
slots
.iter()
.all(|slot| slot["nzo_id"] != first && slot["nzo_id"] != second)
);
}
Loading
Loading