diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2befa4465..125d198db 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -194,6 +194,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "async-broadcast" version = "0.7.2" @@ -878,6 +888,24 @@ dependencies = [ "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]] name = "deranged" version = "0.5.8" @@ -1345,6 +1373,21 @@ dependencies = [ "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]] name = "futures-channel" version = "0.3.31" @@ -1352,6 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1419,6 +1463,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1754,6 +1799,25 @@ dependencies = [ "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]] name = "hashbrown" version = "0.12.3" @@ -1902,9 +1966,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2253,6 +2319,7 @@ dependencies = [ "tokio-util", "urlencoding", "uuid", + "wiremock", "zip 2.4.2", ] @@ -2417,6 +2484,12 @@ dependencies = [ "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]] name = "libappindicator" version = "0.9.0" @@ -2720,6 +2793,16 @@ dependencies = [ "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]] name = "num_enum" version = "0.7.5" @@ -6430,6 +6513,29 @@ dependencies = [ "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]] name = "wit-bindgen" version = "0.46.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 664d48bae..bb1e33f6a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -150,6 +150,7 @@ ndk-context = "0.1" [dev-dependencies] tempfile = "3.24.0" +wiremock = "0.6.5" [features] # Exposes the MediaPlayer conformance suite and the `player-conformance` binary diff --git a/src-tauri/src/repository/generation_tests.rs b/src-tauri/src/repository/generation_tests.rs new file mode 100644 index 000000000..5aeaba5bd --- /dev/null +++ b/src-tauri/src/repository/generation_tests.rs @@ -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 `