Files
jellytau/src-tauri/src/repository/server_fixture.rs
T
dtourolleandClaude Opus 5 33e1403981 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>
2026-09-08 20:09:53 +02:00

148 lines
5.1 KiB
Rust

//! 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(),
}
}