test(repository): run the online repository against a real HTTP server
src-tauri/ contained no HTTP mocking of any kind. Every test of the ~4,800-line
online adapter asserted on a constructed URL string; not one exercised a
response. So "works against both server generations" was not merely untested, it
was unfalsifiable.
Adds wiremock (a project dev-dependency, so no CI image change — the toolchain
rule is about system packages) and a FakeJellyfin fixture that reports a chosen
version. The repository it hands back resolves its capabilities from exactly that
string via the production path, so a test running against both generations is
running the real resolution rather than a stubbed one.
Eight cross-generation tests, each asserting on what the client actually put on
the wire or did with a response it actually received:
- every request carries Authorization: MediaBrowser and no X-Emby-Authorization
- a listing parses into domain items on both generations
- a type-filtered listing puts Recursive on the wire
- libraries resolve through the user-scoped route on both
- flipping user_scoped_item_routes really changes the request and still parses,
so the alternative shape is exercised rather than being untested code waiting
to be switched on
- favourites send Filters=IsFavorite and omit the type filter under All scope
- player-facing URLs carry ApiKey= and never api_key=
- capabilities come from the version the server reported
The auth test was verified to fail when the legacy header is reintroduced into
get_json_inner, so it is a guard rather than decoration.
HttpClient keeps https_only(true) in production; a #[cfg(test)] constructor
allows the plaintext loopback wiremock serves. Weakening the real one to make
testing possible would trade the thing that stops a downgrade putting a session
token in clear for the thing meant to protect it.
The rule this module states and follows: assert against a response from a mock
server, never against a mock that re-derives the thing under test. That is the
mistake the deleted online_integration_test.rs made, and it shipped a broken
download endpoint while staying green.
TRACES: UR-085 | DR-281 | IT-019, IT-020, IT-021, IT-022, IT-023, IT-024, IT-025, IT-026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+106
@@ -194,6 +194,16 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert-json-diff"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -878,6 +888,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deadpool"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||||
|
dependencies = [
|
||||||
|
"deadpool-runtime",
|
||||||
|
"lazy_static",
|
||||||
|
"num_cpus",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deadpool-runtime"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -1345,6 +1373,21 @@ dependencies = [
|
|||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1352,6 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1419,6 +1463,7 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -1754,6 +1799,25 @@ dependencies = [
|
|||||||
"syn 2.0.112",
|
"syn 2.0.112",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.12.1",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1902,9 +1966,11 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@@ -2253,6 +2319,7 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"wiremock",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2417,6 +2484,12 @@ dependencies = [
|
|||||||
"selectors 0.24.0",
|
"selectors 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libappindicator"
|
name = "libappindicator"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2720,6 +2793,16 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_cpus"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num_enum"
|
name = "num_enum"
|
||||||
version = "0.7.5"
|
version = "0.7.5"
|
||||||
@@ -6430,6 +6513,29 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wiremock"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||||
|
dependencies = [
|
||||||
|
"assert-json-diff",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"deadpool",
|
||||||
|
"futures",
|
||||||
|
"http",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ ndk-context = "0.1"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.24.0"
|
||||||
|
wiremock = "0.6.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
|
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! The online repository, exercised against a real HTTP server on both Jellyfin
|
||||||
|
//! generations.
|
||||||
|
//!
|
||||||
|
//! These are the tests DR-281 exists for: every assertion here is about what the
|
||||||
|
//! client actually put on the wire, or about what it did with a response it
|
||||||
|
//! actually received. Nothing here reimplements a URL builder.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-085 | DR-281
|
||||||
|
|
||||||
|
use super::server_fixture::{target, FakeJellyfin, BOTH_GENERATIONS, V10_11, V12};
|
||||||
|
use super::types::{GetItemsOptions, SearchScope};
|
||||||
|
use super::MediaRepository;
|
||||||
|
|
||||||
|
/// Jellyfin 12.0 disables `X-Emby-Authorization` by default — including on
|
||||||
|
/// upgraded servers, via a migration that flips `EnableLegacyAuthorization` to
|
||||||
|
/// false. `Authorization` with the same `MediaBrowser` scheme is ungated on both
|
||||||
|
/// generations, so there is one correct spelling rather than a branch.
|
||||||
|
///
|
||||||
|
/// This is the assertion that would have caught the breakage: it looks at the
|
||||||
|
/// header the server received, not at a string the client built.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-287 | IT-019
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_request_authenticates_with_the_non_deprecated_header() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let repo = fake.repository();
|
||||||
|
|
||||||
|
repo.get_libraries().await.expect("libraries");
|
||||||
|
|
||||||
|
let request = fake.only_request().await;
|
||||||
|
|
||||||
|
let auth = request
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.unwrap_or_else(|| panic!("{version}: no Authorization header was sent"))
|
||||||
|
.to_str()
|
||||||
|
.expect("header is ascii");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
auth.starts_with("MediaBrowser "),
|
||||||
|
"{version}: Authorization must use the MediaBrowser scheme, got {auth:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
auth.contains(r#"Token="token-abc""#),
|
||||||
|
"{version}: the token must reach the server, got {auth:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
request.headers.get("x-emby-authorization").is_none(),
|
||||||
|
"{version}: X-Emby-Authorization is disabled by default on 12.0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listing must parse into domain items on both generations. `BaseItemDto` was
|
||||||
|
/// verified to be purely additive between 10.11.5 and 12.0, so one parse path is
|
||||||
|
/// correct for both — this is the test that would notice if that stopped holding.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-085 | DR-281 | IT-020
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listing_parses_on_both_generations() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let result = fake
|
||||||
|
.repository()
|
||||||
|
.get_items("lib-1", None)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("{version}: listing failed: {e:?}"));
|
||||||
|
|
||||||
|
assert_eq!(result.items.len(), 1, "{version}");
|
||||||
|
assert_eq!(result.items[0].id, "item-1", "{version}");
|
||||||
|
assert_eq!(result.items[0].name, "A Film", "{version}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin 12.0 defaults `recursive` to true when the parent is a library
|
||||||
|
/// folder and `IncludeItemTypes` is set, where 10.11 listed immediate children —
|
||||||
|
/// the identical request, a different result set. The client must state it, so
|
||||||
|
/// that the two generations agree.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-288 | IT-021
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_type_filtered_listing_states_recursive_on_the_wire() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
|
||||||
|
fake.repository()
|
||||||
|
.get_items(
|
||||||
|
"lib-1",
|
||||||
|
Some(GetItemsOptions {
|
||||||
|
include_item_types: Some(vec!["Movie".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("listing");
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(
|
||||||
|
sent.contains("Recursive="),
|
||||||
|
"{version}: without an explicit Recursive the two generations disagree: {sent}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library listing goes to the route the capabilities selected, and comes
|
||||||
|
/// back parsed. Both generations still serve the user-scoped family — only six
|
||||||
|
/// routes were removed in 12.0 and none of them are these.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-085 | DR-282 | IT-022
|
||||||
|
#[tokio::test]
|
||||||
|
async fn libraries_resolve_on_both_generations() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let libraries = fake
|
||||||
|
.repository()
|
||||||
|
.get_libraries()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("{version}: {e:?}"));
|
||||||
|
|
||||||
|
assert_eq!(libraries.len(), 1, "{version}");
|
||||||
|
assert_eq!(libraries[0].id, "lib-1", "{version}");
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(sent.starts_with("/Users/user-1/Views"), "{version}: {sent}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flipping the user-scoped flag must actually change the wire request, and the
|
||||||
|
/// response must still parse. Nothing selects `false` today, so without this the
|
||||||
|
/// alternative route shape would be untested code waiting to be switched on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-282 | IT-023
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_alternative_route_shape_works_end_to_end() {
|
||||||
|
let fake = FakeJellyfin::start(V12).await;
|
||||||
|
|
||||||
|
let mut capabilities = super::capabilities::ServerCapabilities::from_reported(V12);
|
||||||
|
capabilities.user_scoped_item_routes = false;
|
||||||
|
let repo = fake.repository().with_capabilities(capabilities);
|
||||||
|
|
||||||
|
let result = repo.get_items("lib-1", None).await.expect("listing");
|
||||||
|
assert_eq!(result.items.len(), 1);
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(sent.starts_with("/Items?"), "{sent}");
|
||||||
|
assert!(sent.contains("userId=user-1"), "{sent}");
|
||||||
|
assert!(!sent.contains("/Users/"), "{sent}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Favourites carry the filter that makes them favourites, on both generations.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067, UR-085 | DR-281 | IT-024
|
||||||
|
#[tokio::test]
|
||||||
|
async fn favourites_filter_reaches_the_server() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
|
||||||
|
fake.repository()
|
||||||
|
.get_favorites(SearchScope::All, None)
|
||||||
|
.await
|
||||||
|
.expect("favourites");
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(sent.contains("Filters=IsFavorite"), "{version}: {sent}");
|
||||||
|
assert!(
|
||||||
|
!sent.contains("IncludeItemTypes"),
|
||||||
|
"{version}: All scope must omit the type filter rather than send a \
|
||||||
|
union, which would drop every type nobody enumerated: {sent}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stream URL is handed to mpv / ExoPlayer / an HTML5 `<video>`, none of which
|
||||||
|
/// can set a header — so its token must ride in the query string. `ApiKey` is
|
||||||
|
/// ungated on both generations and is what the server itself emits; `api_key` is
|
||||||
|
/// gated off by default on 12.0.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004, UR-085 | DR-287 | IT-025
|
||||||
|
#[tokio::test]
|
||||||
|
async fn player_facing_urls_carry_the_ungated_query_token() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let url = fake
|
||||||
|
.repository()
|
||||||
|
.get_audio_stream_url("track-1")
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("{version}: {e:?}"));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
url.contains("ApiKey=token-abc"),
|
||||||
|
"{version}: a player cannot send a header, so the token must be in \
|
||||||
|
the query — and spelled ApiKey: {url}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!url.contains("api_key="),
|
||||||
|
"{version}: api_key is disabled by default on 12.0: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The capability resolution is driven by what the server reported, not by a
|
||||||
|
/// value a test poked in — this is what makes the other tests here meaningful.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-280 | IT-026
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capabilities_come_from_the_version_the_server_reported() {
|
||||||
|
use super::capabilities::ServerGeneration;
|
||||||
|
|
||||||
|
let old = FakeJellyfin::start(V10_11).await;
|
||||||
|
assert_eq!(
|
||||||
|
old.repository().capabilities().generation,
|
||||||
|
ServerGeneration::V10_11
|
||||||
|
);
|
||||||
|
|
||||||
|
let new = FakeJellyfin::start(V12).await;
|
||||||
|
assert_eq!(
|
||||||
|
new.repository().capabilities().generation,
|
||||||
|
ServerGeneration::V12Plus
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!new.repository()
|
||||||
|
.capabilities()
|
||||||
|
.supports_manifest_container_direct_play,
|
||||||
|
"12.0 makes manifest-container sources ineligible for direct play"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! A fake Jellyfin server the online repository can actually talk to.
|
||||||
|
//!
|
||||||
|
//! # Why this exists
|
||||||
|
//!
|
||||||
|
//! Before it, `src-tauri/` contained no HTTP mocking of any kind. Every test of
|
||||||
|
//! the ~4,800-line online adapter asserted on a *constructed URL string*, and
|
||||||
|
//! not one exercised a response. That has a specific, recorded cost: a deleted
|
||||||
|
//! test file re-implemented the URL builders inside its own mock and then
|
||||||
|
//! asserted against itself, and `online.rs` still carries the comment recording
|
||||||
|
//! that the production builder meanwhile shipped a `/Videos/{id}/download`
|
||||||
|
//! endpoint which 404s on real servers — silently breaking every download while
|
||||||
|
//! the "test" stayed green.
|
||||||
|
//!
|
||||||
|
//! So the rule here is: **assert against a response from a mock *server*, never
|
||||||
|
//! against a mock that re-derives the thing under test.** Nothing in this module
|
||||||
|
//! may reimplement anything from `endpoints.rs` or `online.rs`.
|
||||||
|
//!
|
||||||
|
//! # Two generations
|
||||||
|
//!
|
||||||
|
//! [`FakeJellyfin::start`] takes the version string the fake server reports, and
|
||||||
|
//! the repository it hands back resolves its capabilities from exactly that — the
|
||||||
|
//! same path production takes. A test that runs against both generations is
|
||||||
|
//! therefore running the real resolution, not a stubbed one.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-085 | DR-281
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use wiremock::matchers::{method, path_regex};
|
||||||
|
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
|
||||||
|
|
||||||
|
use super::capabilities::ServerCapabilities;
|
||||||
|
use super::online::OnlineRepository;
|
||||||
|
use crate::jellyfin::{HttpClient, HttpConfig};
|
||||||
|
|
||||||
|
/// Jellyfin's current stable line, and the line this client was built against.
|
||||||
|
pub const V12: &str = "12.0.0";
|
||||||
|
pub const V10_11: &str = "10.11.5";
|
||||||
|
|
||||||
|
/// Both live generations. `#[test]`s that care about compatibility iterate this.
|
||||||
|
///
|
||||||
|
/// There is no 11 in the middle: Jellyfin dropped the leading `10` from its
|
||||||
|
/// scheme with 12.0, so what would have been 10.12.0 shipped as `12.0`.
|
||||||
|
pub const BOTH_GENERATIONS: [&str; 2] = [V10_11, V12];
|
||||||
|
|
||||||
|
pub struct FakeJellyfin {
|
||||||
|
server: MockServer,
|
||||||
|
version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeJellyfin {
|
||||||
|
/// Stand up a server reporting `version`, answering any `/Items`-shaped
|
||||||
|
/// query with one item and any `/Users/.../Views` with one library.
|
||||||
|
///
|
||||||
|
/// The response bodies are deliberately minimal: this module's job is to let
|
||||||
|
/// tests observe what the *client* sent, not to re-specify Jellyfin.
|
||||||
|
pub async fn start(version: &str) -> Self {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
|
||||||
|
let item = json!({
|
||||||
|
"Id": "item-1",
|
||||||
|
"Name": "A Film",
|
||||||
|
"Type": "Movie",
|
||||||
|
"IsFolder": false,
|
||||||
|
"ServerId": "srv-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex(r".*/Views$"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"Items": [{
|
||||||
|
"Id": "lib-1",
|
||||||
|
"Name": "Movies",
|
||||||
|
"Type": "CollectionFolder",
|
||||||
|
"CollectionType": "movies",
|
||||||
|
"IsFolder": true,
|
||||||
|
"ServerId": "srv-1",
|
||||||
|
}],
|
||||||
|
"TotalRecordCount": 1,
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Everything else that returns a listing.
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"Items": [item],
|
||||||
|
"TotalRecordCount": 1,
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.respond_with(ResponseTemplate::new(204))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
server,
|
||||||
|
version: version.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A repository pointed at this server, with capabilities resolved from the
|
||||||
|
/// version it reports — the same resolution production performs.
|
||||||
|
pub fn repository(&self) -> OnlineRepository {
|
||||||
|
let http = HttpClient::new_allowing_plaintext_for_tests(HttpConfig::default())
|
||||||
|
.expect("test http client");
|
||||||
|
|
||||||
|
OnlineRepository::new(
|
||||||
|
Arc::new(http),
|
||||||
|
self.server.uri(),
|
||||||
|
"user-1".to_string(),
|
||||||
|
"token-abc".to_string(),
|
||||||
|
)
|
||||||
|
.with_capabilities(ServerCapabilities::from_reported(&self.version))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every request the server received, in order.
|
||||||
|
pub async fn requests(&self) -> Vec<Request> {
|
||||||
|
self.server
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.expect("request recording is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single request received, failing loudly if there was not exactly one.
|
||||||
|
pub async fn only_request(&self) -> Request {
|
||||||
|
let mut received = self.requests().await;
|
||||||
|
assert_eq!(
|
||||||
|
received.len(),
|
||||||
|
1,
|
||||||
|
"expected exactly one request, got {}",
|
||||||
|
received.len()
|
||||||
|
);
|
||||||
|
received.remove(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request target as the server saw it — path plus query.
|
||||||
|
pub fn target(request: &Request) -> String {
|
||||||
|
match request.url.query() {
|
||||||
|
Some(q) => format!("{}?{}", request.url.path(), q),
|
||||||
|
None => request.url.path().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user