The frontend-agnostic half of Purchases. Ported against the Tauri reference, which is the only implementation ever validated by a real user; the Slint controller was read for divergences only and never as a source of truth. Context that sets the bar for all of it: Qobuz does not sell Purchases in this account's region, so no populated screen and no download can be exercised here. Anything merely plausible ships unseen. Hence the tests below are the deliverable as much as the code. Behaviour ported or corrected: - Album "downloaded" now comes from the LOCAL REGISTRY. The reference derives it from `album.tracks.items`, but `getUserPurchases?type=albums` carries no nested tracks page (measured against a live account), so that predicate is unsatisfiable: no album card can ever mark as downloaded and "Hide downloaded" can never hide anything. `get_downloaded_purchase_album_counts` answers it instead, giving a reader at last to the `album_id` column and to the index that has always sat on it. - `album_id` is written by every download path. It used to be set only by the album loop, so a release downloaded track by track produced rows the rule could never attribute. The `/track/get` payload each download already fetches carries the id. - `remove_downloaded_purchase` keys on `(track_id, format_id)`, the table's actual primary key; erasing every format is now a separate, named call. - `sanitize_filename` no longer panics. The 200 cap is in BYTES and `truncate` panics off a char boundary — reachable because the mapping keeps Unicode alphanumerics. Measured: the crash belongs to the THREE-byte class (CJK), where 200 % 3 lands mid-character; two-byte scripts happen to land on a boundary. The cap stays in bytes so no already-downloaded path moves. - The purchase path sends `/album/get` and `/track/get` UNSIGNED, matching both the reference and Qobuz's own desktop client, whose request helper takes a sign flag and passes it false for these and true only for `getFileUrl`. That signature is the entitlement proof and is untouched. - The purchase-type guard runs before any I/O; a bogus type no longer issues a live authenticated GET before failing. - Track subtitles survive. `PurchaseTrack` gained `version` and `build_purchase_album` maps it from the catalog track, closing a latent regression where every purchased track lost its edition. - Downloads write tags, `cover.jpg`/`back.jpg` and goodies. All three are best-effort: the audio file is the deliverable and is already on disk and registered before any of it runs. - `goodies` is deserialized leniently. Its populated shape has never been observed — it comes back empty on every album nobody owns — and `/album/get` is parsed with the strict structs, so an unexpected shape would take the whole album down rather than merely hide a booklet. Riding along, both found while building the above and both unrelated to Purchases: - The metadata EDITOR silently discarded saves. `write_album_tags_to_files` picked its write target as "the primary tag, or else the first one I can find", and lofty refuses to WRITE a tag type a container only supports READING — silently, skipping it rather than erroring. A FLAC carrying an ID3v2 tag has no Vorbis comments, so every edited field went into that ID3v2 tag, the save dropped the lot, and the call returned Ok. The user pressed Save, saw a success toast, and the file never changed. Same defect, same fix, in the new purchase tagger. - `fetch_asset_bytes` does not reuse the audio downloader. That one sizes its buffer from the response's own Content-Length and sets no total timeout, which is fine for a track from a trusted CDN and wrong for an arbitrary goodie URL on a machine that hard-freezes under memory pressure. Tests: 277 across the four crates, covering every acceptance item the contract set. The signature preimage character for character, the pagination loop including the zero-total case, the sanitizer against the inputs that used to panic, every deserializer default, and the wire envelopes against real bytes captured from a live account. A download harness stands up a local HTTP server and drives the pipeline end to end — real bytes over a real socket, a real FLAC tagged and read back, a real database — and the two tag regressions were both verified to FAIL against the old code before being accepted. cc
143 lines
6.6 KiB
Rust
143 lines
6.6 KiB
Rust
//! The purchase envelopes, checked against REAL CAPTURED BYTES.
|
|
//!
|
|
//! # Why these are not the synthetic tests in `types.rs`
|
|
//!
|
|
//! The unit tests beside the models assert the shapes we BELIEVE Qobuz sends.
|
|
//! These assert the shapes it ACTUALLY sent. The files in `tests/fixtures/purchases/`
|
|
//! were captured on 2026-08-15 against the owner's own account, read-only and with
|
|
//! their authorisation, and the ids and login were redacted afterwards. They
|
|
//! replace the inferred dummy response this project started from — before them,
|
|
//! every claim about the purchase envelope was a reading of somebody else's code.
|
|
//!
|
|
//! They matter more than usual here because nobody on this team can smoke-test
|
|
//! Purchases: Qobuz does not sell it in the owner's region, so no screen will
|
|
//! ever be exercised locally. A fixture is the only thing in the loop that
|
|
//! cannot drift from what the server really does.
|
|
//!
|
|
//! # What they can and cannot prove
|
|
//!
|
|
//! Every capture is from an account that owns NOTHING, so `items` is empty in
|
|
//! all of them. They pin the ENVELOPE — which keys exist, which are absent, what
|
|
//! the pagination scalars look like — and they cannot say anything about a
|
|
//! populated item. That gap is real and is why the contract's §11-1 question
|
|
//! could not be answered from this account.
|
|
|
|
use qbz_models::{PurchaseIdsResponse, PurchaseResponse};
|
|
|
|
fn fixture(name: &str) -> String {
|
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures/purchases")
|
|
.join(name);
|
|
std::fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("missing capture {}: {e}", path.display()))
|
|
}
|
|
|
|
/// The unfiltered call returns BOTH pages, and the server's default `limit` is
|
|
/// 50 — not 500, which is what the client asks for when it paginates.
|
|
#[test]
|
|
fn unfiltered_purchases_carry_both_pages_and_a_default_limit_of_50() {
|
|
let r: PurchaseResponse = serde_json::from_str(&fixture("getUserPurchases-noparams.json"))
|
|
.expect("the captured envelope must deserialize");
|
|
|
|
assert_eq!(r.albums.limit, 50, "the server's default page size");
|
|
assert_eq!(r.tracks.limit, 50);
|
|
assert_eq!(r.albums.offset, 0);
|
|
assert_eq!(r.albums.total, 0);
|
|
assert!(r.albums.items.is_empty());
|
|
assert!(r.tracks.items.is_empty());
|
|
}
|
|
|
|
/// THE correction that mattered: `?type=albums` **omits the `tracks` key
|
|
/// entirely**. It is not present-and-zeroed, which is what the first reading of
|
|
/// the reference concluded from a function that zeroes the sibling total.
|
|
///
|
|
/// The observable effect is the same — an empty sibling page — which is why the
|
|
/// two-`getUserPurchasesIds`-calls requirement stands either way. But a port that
|
|
/// models a present-but-empty sibling is modelling something the server does not
|
|
/// send, and it is exactly the assumption that made the reference's album
|
|
/// "downloaded" predicate unsatisfiable (contract §11-1).
|
|
#[test]
|
|
fn a_typed_albums_response_omits_the_tracks_key_entirely() {
|
|
let raw = fixture("getUserPurchases-type-albums.json");
|
|
assert!(
|
|
!raw.contains("\"tracks\""),
|
|
"the capture must NOT contain a tracks key — that absence is the point"
|
|
);
|
|
|
|
let r: PurchaseResponse = serde_json::from_str(&raw).expect("must still deserialize");
|
|
assert_eq!(r.albums.limit, 500, "the client's requested page size comes back");
|
|
assert!(r.tracks.items.is_empty(), "the absent sibling defaults, never fails");
|
|
assert_eq!(r.tracks.total, 0);
|
|
}
|
|
|
|
/// And symmetrically for tracks, so nobody assumes the omission is albums-only.
|
|
#[test]
|
|
fn a_typed_tracks_response_omits_the_albums_key_entirely() {
|
|
let raw = fixture("getUserPurchases-type-tracks.json");
|
|
assert!(!raw.contains("\"albums\""));
|
|
|
|
let r: PurchaseResponse = serde_json::from_str(&raw).expect("must still deserialize");
|
|
assert_eq!(r.tracks.limit, 500);
|
|
assert!(r.albums.items.is_empty());
|
|
}
|
|
|
|
/// The ids envelope is a DIFFERENT page shape: `{total, items}` with no `offset`
|
|
/// and no `limit`.
|
|
///
|
|
/// This is the one that would have failed loudly and been misdiagnosed. Both tab
|
|
/// counters are read from `.total` here, and the page sits behind a lenient
|
|
/// deserializer that swallows a parse failure into an EMPTY page. Had `offset`
|
|
/// and `limit` been required fields, both counters would have read 0 forever with
|
|
/// nothing logged, and the obvious conclusion would have been "the user owns
|
|
/// nothing" rather than "the page did not parse".
|
|
#[test]
|
|
fn the_ids_envelope_has_no_offset_or_limit_and_still_yields_its_total() {
|
|
let raw = fixture("getUserPurchasesIds-noparams.json");
|
|
assert!(!raw.contains("\"offset\""), "the ids page carries no offset");
|
|
assert!(!raw.contains("\"limit\""), "nor a limit");
|
|
|
|
let r: PurchaseIdsResponse = serde_json::from_str(&raw).expect("must deserialize");
|
|
assert_eq!(r.albums.total, 0);
|
|
assert_eq!(r.tracks.total, 0);
|
|
assert_eq!(r.albums.offset, 0, "defaulted, not sent");
|
|
assert_eq!(r.albums.limit, 0, "defaulted, not sent");
|
|
}
|
|
|
|
/// The typed ids call drops the sibling too — so the per-type totals genuinely
|
|
/// need one call each, which is what the reference does and what the contract
|
|
/// requires. A two-parameter port that fires a single combined call reads one
|
|
/// type's total and silently reports 0 for the other.
|
|
#[test]
|
|
fn a_typed_ids_response_omits_its_sibling_which_is_why_two_calls_are_needed() {
|
|
let raw = fixture("getUserPurchasesIds-type-albums.json");
|
|
assert!(!raw.contains("\"tracks\""));
|
|
|
|
let r: PurchaseIdsResponse = serde_json::from_str(&raw).expect("must deserialize");
|
|
assert_eq!(r.albums.total, 0);
|
|
assert_eq!(r.tracks.total, 0, "absent sibling defaults to 0, never fails");
|
|
}
|
|
|
|
/// Every capture carries a top-level `user` block that no model declares, and
|
|
/// none of them may fail because of it. Unknown top-level keys must be ignored —
|
|
/// if Qobuz adds another one tomorrow, the purchases screen must not go blank.
|
|
#[test]
|
|
fn an_unknown_top_level_key_never_fails_a_response() {
|
|
for name in [
|
|
"getUserPurchases-noparams.json",
|
|
"getUserPurchases-type-albums.json",
|
|
"getUserPurchases-type-tracks.json",
|
|
] {
|
|
let raw = fixture(name);
|
|
assert!(raw.contains("\"user\""), "{name} should carry the user block");
|
|
serde_json::from_str::<PurchaseResponse>(&raw)
|
|
.unwrap_or_else(|e| panic!("{name} must ignore the unknown `user` key: {e}"));
|
|
}
|
|
for name in [
|
|
"getUserPurchasesIds-noparams.json",
|
|
"getUserPurchasesIds-type-albums.json",
|
|
] {
|
|
serde_json::from_str::<PurchaseIdsResponse>(&fixture(name))
|
|
.unwrap_or_else(|e| panic!("{name} must ignore the unknown `user` key: {e}"));
|
|
}
|
|
}
|