//! 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 `