* feat(downloads): offline sync for mobile (downloads v2) Replace internal/download with a unified internal/downloads package and add fully-offline download + watch-sync support for mobile clients, across five independently-shippable phases: - Phase 0: reshape the downloads table and the /downloads contract to be device- and format-aware; add GET /downloads/capability; extend DownloadConfig (default-off keys); update the web download hooks/components in lockstep. This is the one approved pre-lock exception to the additive-only /api/v1 rule (the web app is the only consumer and is updated together). - Phase 1: managed device-library entries (create/list/PATCH/delete/serve), keyed on the X-Silo-Device-Id header. - Phase 2: offline playback manifest plus artwork/subtitle proxy endpoints that strip every presigned URL (inline thumbhashes + authenticated proxies). - Phase 3: prepare-to-file (remux + transcode-to-single-file) as a durable, leased artifact queue with startup recovery, hosted on the task manager; playback.PrepareFile emits one +faststart MP4. Adds the admin transcode toggle and per-artifact LRU cleanup. - Phase 4: offline progress reconciliation -- a clamped event_at LWW key plus a server-assigned synced_seq cursor on watch_progress; an optional clamped updated_at on POST /sync/progress and an opaque ?since= cursor on GET /progress (additive; existing callers unaffected). Security & reliability invariants, each with an acceptance test: 1. Server-owned sync ordering: ?since= delta delivery is driven only by the server-assigned synced_seq; the client clock is bounded (event_at, clamped to now+skew) and used only for last-write-wins on the caller's own profile. 2. Full profile+device authorization on every managed endpoint, with a per-profile content/library access re-check before serving any bytes/assets. 3. Durable artifact recovery: a transactionally-claimed (FOR UPDATE SKIP LOCKED), lease-heartbeat, attempt-counted queue with a startup sweep, so no crash strands a download in preparing and concurrent workers never double-encode. Migrations are timestamped Goose files: reshape downloads (device/format); download_artifacts (durable queue); watch_progress event_at/synced_seq. DB-backed acceptance tests skip without SILO_TEST_DATABASE_URL and run in CI; the invariant-1 progress test also runs against the real SQLite backend locally. Client repos (silo-android, silo-apple) consume the reshaped /downloads/* contract and the updated_at/?since= progress fields and require coordinated follow-up. Implements the maintainer-approved v1 capability proposal for offline sync (downloads v2). AI-use disclosure: implemented by Claude (Claude Code) from the approved design doc under docs/superpowers/specs, with human review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(downloads): series & season downloads + client-pull monitoring Build season downloads and a "monitor a series" capability on top of the downloads v2 (offline sync for mobile) work. Season downloads: - POST /downloads accepts season_number (with series:true) to download one season. CreateSeries/CreateSeason share one body via a listEpisodes closure and register managed entries under a shared batch_id (original-only). Episode files are resolved in a single batched query. Series monitoring (auto-download), client-driven: - New device-scoped download_subscriptions table with a Sonarr-style mode (all | future | latest_season | specific_seasons), a client-enforced delete_watched flag, and a max_storage_bytes cap. The server never deletes on-device files; retention and the hard cap are the client's, the server only soft-gates registration. - The client calls POST /downloads/subscriptions/sync on open / background refresh; the server registers the in-scope, not-yet-downloaded episodes (idempotent via the managed-entry unique index) and the device pulls them on its own schedule. No background worker and no dependency on the notifications subsystem. latest_season follows new seasons (>= subscribe-time season); future excludes the back catalog via air date. - Subscription CRUD + sync are profile+device authorized (device id from the X-Silo-Device-Id header only) with a per-request content-access re-check. The capability endpoint advertises season_download / series_monitoring / monitoring_modes. Also lands the downloads-v2 work already present in the tree: durable artifact (remux/transcode) preparation and offline watch-progress reconciliation, plus the design-spec updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WIP: epitaxy pre-switch from feat/downloads-v2-offline-sync * test(downloads): fix deterministic ID collision in reconcile test Artifact IDs are time-sortable, so two artifacts created in the same moment share their first 8 chars; combined with a captured timestamp the two preparing-download IDs collided on downloads_pkey. Use the full artifact ID, which is unique per row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): support sqlite userdb backend for managed downloads With the sqlite userdb backend, profiles live only in per-user SQLite stores and public.user_profiles stays empty, so user_devices' profile FK made every managed create/subscription/offline-sync request fail with an FK violation. Drop the FK (shared Postgres tables must not FK profile tables — same rule as notifications) and replace the lost cascade with an app-level purge on profile deletion, wired through ProfileHandler for both backends. DB-backed regression tests cover the no-Postgres-profile-row path and the purge cascade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): dispatch encode kick asynchronously triggerDrain invoked the kick inline, and the kick (taskmanager RunTask) executes the encode task on the caller's goroutine — so a POST /api/v1/downloads with a bitrate quality blocked the HTTP request on the entire queue drain, ffmpeg encodes included, delaying the 202 by minutes on an idle queue. Dispatch the kick on a goroutine; the task manager already serializes concurrent runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): enforce per-user quota on the encode pipeline Two gaps let a user bypass MaxConcurrentPerUser entirely for prepared downloads: artifact-backed rows are created in 'preparing' (never 'queued'/'downloading'), which CountActiveByUser didn't count, and createArtifactDownload enqueued the encode job before limiter.Check, so even a 429-rejected request left a job the worker would transcode. Count 'preparing' as active and check the limiter before Ensure; managed replacements stay quota-exempt since they don't add a row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): protect ephemeral artifact links from LRU eviction HasActiveLink only counted managed (device_id IS NOT NULL) rows, so under a byte budget Cleanup could delete an artifact still referenced by a ready-but-unfetched ephemeral web download — permanently 404ing a row the API kept listing as ready (the artifact row is gone, so recovery can't re-queue it). Any non-terminal link now protects the artifact; only artifacts whose links are all cancelled/failed/revoked are evictable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): batch manifests skip bad entries instead of failing whole batch One deleted or access-filtered episode made GET /downloads/batches/{id}/manifests 404 for the entire season, so a client could no longer fetch manifests for the still-valid entries. Report unbuildable entries in a skipped[] array (revoked | not_found | error) alongside the delivered manifests, mirroring the create path's skip idiom. Also cut the batch cost: the shared series detail is resolved once per batch instead of once per episode, and buildSubtitles reuses the already-loaded media file instead of re-querying it per manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): wrap DO block in StatementBegin/End markers Under NO TRANSACTION goose splits statements on semicolons, so the dollar-quoted DO block failed every fresh install with 'unterminated dollar-quoted string' (SQLSTATE 42601). Already-applied databases are unaffected. Same fix is being applied to main; identical content merges cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): allow season 0 (Specials) in season downloads season_number was a plain int dispatched with '> 0', so requesting the Specials season was indistinguishable from omitting the field and silently broadened to a full-series download. Dispatch on pointer presence, treat 0 as the Specials season, and reject negatives with 400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): capability quality_presets is never JSON null PresetsFor returned a nil slice when downloads are disabled or the user lacks the permission, and Capability's []string{} initialization was immediately overwritten by it — so GET /downloads/capability serialized "quality_presets": null where the contract documents an array. Normalize at the source so every caller inherits the guarantee. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): subscription sync correctness + batched registration Three subscription fixes: - A paused subscription no longer syncs: PATCHing scope (or pausing and changing scope in one request) registered episodes for a monitor the user had just stopped, inconsistently with SyncSubscriptions' guard. - SubModeFuture compares calendar days (UTC): air_date is date-only, so the strict instant comparison permanently excluded episodes airing the same day the user subscribed; episodes with no air date now fall back to their ingest time instead of never registering. - Registration is one batched fetch (GetManagedEntriesByKeys) plus one batched INSERT ... ON CONFLICT DO NOTHING RETURNING (CreateManagedEntriesBatch) instead of a SELECT+INSERT per episode — a 300-episode series cost ~600 sequential round trips per request and every no-op sync re-walked the full set. RETURNING yields exactly the new rows, so the sync response's 'registered' count now honestly reports 0 in the steady state instead of the full in-scope count on every app open. The now-unused InsertManagedEntryIfAbsent is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(userstore): stamp triggers own the event_at LWW key MarkProgressBatch (jellycompat series mark-played) advanced updated_at but never event_at, and both stamp triggers only defaulted event_at when NULL — so a queued offline event with a client time between the row's old event_at and the mark could win SetProgressIfNewer and resurrect a stale resume position that then re-synced to every device. Make the triggers authoritative instead of adding a tenth hand-written SET clause: whenever an UPDATE changes updated_at without explicitly changing event_at, the trigger advances the LWW key; writes that do set event_at (offline sync's clamped client event time) keep their value. Postgres gets a CREATE OR REPLACE migration; SQLite gets a v12 userdb migration that drops and reinstalls the trigger bodies (CREATE TRIGGER IF NOT EXISTS never replaces). Conformance tests cover both batch paths, the preserved-client-time invariant, and the v11→v12 upgrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): lifecycle hygiene — squash migrations, dead status, stale-row sweeps Migrations: fold the 20260621 corrective migration back into the base Downloads V2 migrations (its columns/constraints already exist there) and fix the reshape Down, which re-added the narrow status CHECK without collapsing managed-lifecycle rows first — rollback aborted on any DB with preparing/ready/revoked rows; validated against a live row. Branch databases that applied the corrective migration need its version row removed: DELETE FROM goose_db_version WHERE version_id = 20260621020459. Code: drop the dead 'registered' status (nothing ever wrote it; the lifecycle is preparing -> ready; 'revoked' stays reserved for the planned admin revoke flow) along with unused KindDirect and ErrInvalidFormat. Sweeps: Cleanup now runs an age-based hygiene pass independent of the byte budget — cold terminally-failed artifacts (with .part leftovers), orphaned ready artifacts no download row references, and ephemeral web rows older than their convenience-record lifetime (also unpinning their artifacts and bounding GET /downloads growth). The byte budget remains the disk quota per the limits & restrictions design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(downloads): sync API doc with v2 fixes; HEAD on file route; Android handoff Document the contract changes from the review fixes: batch-manifest skipped[] shape, honest subscription 'registered' semantics, season 0 = Specials, always-array quality_presets, bytes_sent actual behavior, ephemeral 7-day retention, header-pairing requirement, progress-delta deletion caveat, and the ready/failed push event schema (new §9.4). Add an Android client handoff section (§11) mirroring the Apple one, register HEAD on /downloads/{id}/file for download stacks that probe before ranged GETs, and add season_number to the web create-request type. Flag the /direct-download session-token-in-URL tradeoff; a short-lived download-scoped URL is a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: consolidate download/progress helpers, prune dead code, gate sweeps Behavior-preserving consolidation from the Downloads V2 review: - appendVideoFilterArgs: one home for the burn-in/hwaccel -vf selection, shared by the HLS builder and the single-file prepare builder (the drift pattern that already bit tone-mapping once). - userstore.ResolveProgressState: one home for the min-resume/watched threshold rule, replacing five identical copies across both store backends and the offline-sync ingest. - Download file selection ranks resolutions via access.CompareQuality (adds 4320p, agrees with playback) instead of a private switch. - writeSubtitle uses the shared subtitles.SubtitleContentType mapping. - config.DefaultTranscodeDir replaces three '/tmp/silo-transcode' literals. - Read-side quality/revision defaulting helpers removed: insertArgs plus the NOT NULL/CHECK schema already guarantee the invariant. - Dead code removed: Repository.ListByUser, SubscriptionRepository. ListActiveBySeries, and the stale auto-register-worker comments (the design is client-pull; no worker exists). - Redundant left-prefix indexes dropped from the base migrations (their unique indexes serve the same prefixes). - recover()'s disk-presence sweep and the stale-row hygiene sweep run on startup then hourly instead of every 30s tick (both are O(cache size)). - gofmt/prettier fixes for pre-existing drift in handlers/playback.go and pages/Profiles.tsx. Deferred (noted for follow-ups): quality-ladder preset table collides with the drafted download limits & restrictions design, which specifies its own ladder helper; Download-literal construction consolidation and the managed-identity value object remain open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(downloads): draft download limits & restrictions design Design input for the follow-up v1 capability proposal (quality ceiling, batch size cap, per-user quantity/bandwidth overrides). Committed with downloads v2 because the remediation work explicitly defers the quality ladder refactor and revocation wiring to this spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(progress): reject malformed updated_at; clamp negative progress inputs Review findings on #258: - A malformed (non-RFC3339) updated_at in POST /sync/progress previously parsed to the zero time, which clampEventAt treated as "now" — letting a stale offline event win LWW as a fresh server-time write. The item is now rejected with a per-item error instead. - ResolveProgressState now clamps negative position/duration before classification so no backend can persist negative progress through UpdateProgress/SetProgress. - The online-write event_at invariant test is table-driven over both SetProgress and UpdateProgress, which share the same contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): close review gaps — permission gates, file-access recheck, artifact-true manifests Review findings on #258: - UpdateSubscription now applies the same feature/DownloadAllowed gate as CreateSubscription and SyncSubscriptions; a PATCH could previously re-activate or widen a monitor and register managed rows after an admin disabled downloads or revoked the user. - Serving download bytes (managed and ephemeral) and /direct-download now mirror playback's per-file authorization via catalog.FileAllowedByAccess: library scope and the profile's max playback quality are re-checked at serve time, with artifact-backed rows checked against the artifact's resolution (a 720p transcode of a 4K source stays servable under a 1080p ceiling). - Offline manifests for remux/transcode entries now describe the prepared artifact (container, codecs, resolution, single selected audio track) instead of the catalog source file the client never receives. - ArtifactRepository.Requeue reports ErrNotFound when the row was concurrently swept; ArtifactManager.Ensure recreates the job in that case instead of linking downloads to a dead artifact id. - "No downloadable episodes" is a sentinel (mapped to 404 no_downloadable_episodes) rather than a bare error that surfaced as 500. - Subscription season_numbers are bounds-checked (0–9999) before the int32 narrowing in the repo could silently wrap them. - HandlePatchDownload reuses requireManaged instead of hand-rolling the same managed-identity checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2066 lines
69 KiB
Go
2066 lines
69 KiB
Go
// Package storetest provides a shared conformance test suite for UserStore
|
|
// implementations. Both SQLite and Postgres backends run these same tests.
|
|
package storetest
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// RunProgressSince runs only the offline-sync progress-reconciliation
|
|
// conformance test (invariant 1). It is exposed separately so a backend can
|
|
// exercise the offline-sync behavior without the full suite.
|
|
func RunProgressSince(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
t.Run("DeltaCursor", func(t *testing.T) {
|
|
testProgressSince(t, newStore)
|
|
})
|
|
t.Run("OnlineWriteAdvancesEventAt", func(t *testing.T) {
|
|
testOnlineWriteAdvancesEventAt(t, newStore)
|
|
})
|
|
t.Run("BatchWritesAdvanceEventAt", func(t *testing.T) {
|
|
testBatchWritesAdvanceEventAt(t, newStore)
|
|
})
|
|
}
|
|
|
|
// RunSuite runs all conformance tests against a UserStore implementation.
|
|
// The newStore function should return a fresh, empty store for each test.
|
|
func RunSuite(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
t.Run("Profiles", func(t *testing.T) {
|
|
testProfiles(t, newStore)
|
|
})
|
|
t.Run("Progress", func(t *testing.T) {
|
|
testProgress(t, newStore)
|
|
})
|
|
t.Run("ProgressSince", func(t *testing.T) {
|
|
testProgressSince(t, newStore)
|
|
})
|
|
t.Run("OnlineWriteAdvancesEventAt", func(t *testing.T) {
|
|
testOnlineWriteAdvancesEventAt(t, newStore)
|
|
})
|
|
t.Run("BatchWritesAdvanceEventAt", func(t *testing.T) {
|
|
testBatchWritesAdvanceEventAt(t, newStore)
|
|
})
|
|
t.Run("Favorites", func(t *testing.T) {
|
|
testFavorites(t, newStore)
|
|
})
|
|
t.Run("Watchlist", func(t *testing.T) {
|
|
testWatchlist(t, newStore)
|
|
})
|
|
t.Run("Collections", func(t *testing.T) {
|
|
testCollections(t, newStore)
|
|
})
|
|
t.Run("Settings", func(t *testing.T) {
|
|
testSettings(t, newStore)
|
|
})
|
|
t.Run("SubtitlePreferences", func(t *testing.T) {
|
|
testSubtitlePreferences(t, newStore)
|
|
})
|
|
t.Run("AudioPreferences", func(t *testing.T) {
|
|
testAudioPreferences(t, newStore)
|
|
})
|
|
t.Run("LibraryPlaybackPreferences", func(t *testing.T) {
|
|
testLibraryPlaybackPreferences(t, newStore)
|
|
})
|
|
t.Run("ProgressHints", func(t *testing.T) {
|
|
testProgressHints(t, newStore)
|
|
})
|
|
t.Run("SectionOverrides", func(t *testing.T) {
|
|
testSectionOverrides(t, newStore)
|
|
})
|
|
t.Run("SectionOverridesUserAddedFields", func(t *testing.T) {
|
|
testSectionOverridesUserAddedFields(t, newStore)
|
|
})
|
|
t.Run("HomeDismissals", func(t *testing.T) {
|
|
testHomeDismissals(t, newStore)
|
|
})
|
|
}
|
|
|
|
func testProfiles(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Create
|
|
p := userstore.Profile{
|
|
ID: "prof-1",
|
|
Name: "Alice",
|
|
Avatar: "avatar1.png",
|
|
IsChild: false,
|
|
MaxContentRating: "R",
|
|
QualityPreference: "1080p",
|
|
Language: "en",
|
|
SubtitleLanguage: "es",
|
|
SubtitleMode: "auto",
|
|
AutoSkipIntro: true,
|
|
AutoSkipCredits: false,
|
|
AutoSkipRecap: false,
|
|
AutoPlayNextPreview: false,
|
|
LibraryRestrictionsEnabled: true,
|
|
AllowedLibraryIDs: []int{1, 3},
|
|
MaxPlaybackQuality: "1080p",
|
|
}
|
|
if err := store.CreateProfile(ctx, p); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Get
|
|
got, err := store.GetProfile(ctx, "prof-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProfile: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("GetProfile returned nil")
|
|
}
|
|
if got.Name != "Alice" {
|
|
t.Errorf("Name = %q, want %q", got.Name, "Alice")
|
|
}
|
|
if got.AutoSkipIntro != true {
|
|
t.Errorf("AutoSkipIntro = %v, want true", got.AutoSkipIntro)
|
|
}
|
|
if !got.LibraryRestrictionsEnabled {
|
|
t.Errorf("LibraryRestrictionsEnabled = %v, want true", got.LibraryRestrictionsEnabled)
|
|
}
|
|
if len(got.AllowedLibraryIDs) != 2 || got.AllowedLibraryIDs[0] != 1 || got.AllowedLibraryIDs[1] != 3 {
|
|
t.Errorf("AllowedLibraryIDs = %v, want [1 3]", got.AllowedLibraryIDs)
|
|
}
|
|
if got.MaxPlaybackQuality != "1080p" {
|
|
t.Errorf("MaxPlaybackQuality = %q, want %q", got.MaxPlaybackQuality, "1080p")
|
|
}
|
|
|
|
// Get non-existent
|
|
missing, err := store.GetProfile(ctx, "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("GetProfile(nonexistent): %v", err)
|
|
}
|
|
if missing != nil {
|
|
t.Errorf("GetProfile(nonexistent) = %v, want nil", missing)
|
|
}
|
|
|
|
// List
|
|
profiles, err := store.ListProfiles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListProfiles: %v", err)
|
|
}
|
|
if len(profiles) != 1 {
|
|
t.Fatalf("ListProfiles returned %d profiles, want 1", len(profiles))
|
|
}
|
|
if !profiles[0].LibraryRestrictionsEnabled {
|
|
t.Errorf("ListProfiles()[0].LibraryRestrictionsEnabled = %v, want true", profiles[0].LibraryRestrictionsEnabled)
|
|
}
|
|
if len(profiles[0].AllowedLibraryIDs) != 2 || profiles[0].AllowedLibraryIDs[0] != 1 || profiles[0].AllowedLibraryIDs[1] != 3 {
|
|
t.Errorf("ListProfiles()[0].AllowedLibraryIDs = %v, want [1 3]", profiles[0].AllowedLibraryIDs)
|
|
}
|
|
if profiles[0].MaxPlaybackQuality != "1080p" {
|
|
t.Errorf("ListProfiles()[0].MaxPlaybackQuality = %q, want %q", profiles[0].MaxPlaybackQuality, "1080p")
|
|
}
|
|
|
|
// Update
|
|
newName := "Alice Updated"
|
|
skipCredits := true
|
|
restrictionsEnabled := false
|
|
allowedLibraryIDs := []int{2, 4}
|
|
maxPlaybackQuality := "720p"
|
|
if err := store.UpdateProfile(ctx, "prof-1", userstore.UpdateProfileInput{
|
|
Name: &newName,
|
|
AutoSkipCredits: &skipCredits,
|
|
LibraryRestrictionsEnabled: &restrictionsEnabled,
|
|
AllowedLibraryIDs: &allowedLibraryIDs,
|
|
MaxPlaybackQuality: &maxPlaybackQuality,
|
|
}); err != nil {
|
|
t.Fatalf("UpdateProfile: %v", err)
|
|
}
|
|
updated, _ := store.GetProfile(ctx, "prof-1")
|
|
if updated.Name != "Alice Updated" {
|
|
t.Errorf("Updated Name = %q, want %q", updated.Name, "Alice Updated")
|
|
}
|
|
if updated.AutoSkipCredits != true {
|
|
t.Errorf("Updated AutoSkipCredits = %v, want true", updated.AutoSkipCredits)
|
|
}
|
|
if updated.LibraryRestrictionsEnabled {
|
|
t.Errorf("Updated LibraryRestrictionsEnabled = %v, want false", updated.LibraryRestrictionsEnabled)
|
|
}
|
|
if len(updated.AllowedLibraryIDs) != 2 || updated.AllowedLibraryIDs[0] != 2 || updated.AllowedLibraryIDs[1] != 4 {
|
|
t.Errorf("Updated AllowedLibraryIDs = %v, want [2 4]", updated.AllowedLibraryIDs)
|
|
}
|
|
if updated.MaxPlaybackQuality != "720p" {
|
|
t.Errorf("Updated MaxPlaybackQuality = %q, want %q", updated.MaxPlaybackQuality, "720p")
|
|
}
|
|
|
|
// PIN set and verify
|
|
pin := "1234"
|
|
if err := store.UpdateProfile(ctx, "prof-1", userstore.UpdateProfileInput{PIN: &pin}); err != nil {
|
|
t.Fatalf("UpdateProfile(PIN): %v", err)
|
|
}
|
|
ok, err := store.VerifyPIN(ctx, "prof-1", "1234")
|
|
if err != nil {
|
|
t.Fatalf("VerifyPIN: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("VerifyPIN returned false for correct PIN")
|
|
}
|
|
ok, err = store.VerifyPIN(ctx, "prof-1", "wrong")
|
|
if err != nil {
|
|
t.Fatalf("VerifyPIN(wrong): %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("VerifyPIN returned true for wrong PIN")
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteProfile(ctx, "prof-1"); err != nil {
|
|
t.Fatalf("DeleteProfile: %v", err)
|
|
}
|
|
deleted, _ := store.GetProfile(ctx, "prof-1")
|
|
if deleted != nil {
|
|
t.Error("GetProfile after delete returned non-nil")
|
|
}
|
|
}
|
|
|
|
func testProgress(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Setup profile
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
noThreshold := userstore.ProgressThresholds{}
|
|
|
|
// SetProgress (unconditional) — position at 500/7200 = 6.9%, above min-resume default
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
wp, err := store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress returned nil")
|
|
}
|
|
if wp.PositionSeconds != 500 {
|
|
t.Errorf("PositionSeconds = %v, want 500", wp.PositionSeconds)
|
|
}
|
|
|
|
// UpdateProgress forward-only: should advance
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-1", 1000, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(forward): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.PositionSeconds != 1000 {
|
|
t.Errorf("PositionSeconds after forward = %v, want 1000", wp.PositionSeconds)
|
|
}
|
|
|
|
// UpdateProgress backward: should NOT go backward
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-1", 600, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(backward): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.PositionSeconds != 1000 {
|
|
t.Errorf("PositionSeconds after backward = %v, want 1000 (unchanged)", wp.PositionSeconds)
|
|
}
|
|
|
|
// SetProgress backward: should work (unconditional)
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(backward): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.PositionSeconds != 500 {
|
|
t.Errorf("PositionSeconds after SetProgress backward = %v, want 500", wp.PositionSeconds)
|
|
}
|
|
|
|
// Completion flag: >90%. Completed rows hold no resume point.
|
|
if err := store.SetProgress(ctx, "p1", "movie-2", 6500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(near end): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-2")
|
|
if !wp.Completed {
|
|
t.Error("Expected Completed=true for >90% progress")
|
|
}
|
|
if wp.PositionSeconds != 0 {
|
|
t.Errorf("PositionSeconds after completion = %v, want 0", wp.PositionSeconds)
|
|
}
|
|
|
|
// Min-resume threshold: below 5% should be silently discarded
|
|
if err := store.SetProgress(ctx, "p1", "movie-tiny", 10, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(below min resume): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-tiny")
|
|
if wp != nil {
|
|
t.Error("Expected nil progress for position below min-resume threshold")
|
|
}
|
|
|
|
// Zero-duration writes should not error and should remain incomplete.
|
|
if err := store.SetProgress(ctx, "p1", "movie-unknown", 0, 0, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress(zero duration): %v", err)
|
|
}
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-unknown", 30, 0, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(zero duration): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-unknown")
|
|
if wp == nil {
|
|
t.Fatal("GetProgress(zero duration) returned nil")
|
|
}
|
|
if wp.Completed {
|
|
t.Error("Expected Completed=false for zero-duration progress")
|
|
}
|
|
|
|
// ListProgress
|
|
all, err := store.ListProgress(ctx, "p1", "all", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListProgress: %v", err)
|
|
}
|
|
if len(all) != 3 {
|
|
t.Errorf("ListProgress returned %d items, want 3", len(all))
|
|
}
|
|
|
|
// ListProgressFiltered with no type/library predicate degrades to the plain
|
|
// status listing. Only this no-filter path is portable across backends: the
|
|
// per-user SQLite store has no catalog tables to push a type/library filter
|
|
// into, so the type/library branches are covered by the pgstore tests.
|
|
completedFiltered, err := store.ListProgressFiltered(ctx, "p1", "completed", nil, nil, 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListProgressFiltered: %v", err)
|
|
}
|
|
if len(completedFiltered) != 1 || completedFiltered[0].MediaItemID != "movie-2" {
|
|
t.Errorf("ListProgressFiltered(completed, no filter) = %+v, want [movie-2]", completedFiltered)
|
|
}
|
|
|
|
// GetProgress non-existent
|
|
wp, err = store.GetProgress(ctx, "p1", "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(nonexistent): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Error("GetProgress(nonexistent) returned non-nil")
|
|
}
|
|
|
|
// AddHistory + ListHistory
|
|
seasonNumber := 1
|
|
episodeNumber := 2
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "episode",
|
|
SeriesProviderIDs: map[string]string{"tmdb": "123"},
|
|
Season: &seasonNumber,
|
|
Episode: &episodeNumber,
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
history, err := store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory: %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Errorf("ListHistory returned %d, want 1", len(history))
|
|
}
|
|
if history[0].Source != userstore.WatchHistorySourceLegacy {
|
|
t.Errorf("ListHistory Source = %q, want %q", history[0].Source, userstore.WatchHistorySourceLegacy)
|
|
}
|
|
if history[0].Identity.StableType != "episode" {
|
|
t.Errorf("ListHistory Identity.StableType = %q, want episode", history[0].Identity.StableType)
|
|
}
|
|
if len(history[0].Identity.ProviderIDs) != 0 {
|
|
t.Errorf("ListHistory Identity.ProviderIDs = %#v, want empty", history[0].Identity.ProviderIDs)
|
|
}
|
|
if history[0].Identity.SeriesProviderIDs["tmdb"] != "123" {
|
|
t.Errorf("ListHistory Identity.SeriesProviderIDs[tmdb] = %q, want 123", history[0].Identity.SeriesProviderIDs["tmdb"])
|
|
}
|
|
if history[0].Identity.Season == nil || *history[0].Identity.Season != seasonNumber {
|
|
t.Errorf("ListHistory Identity.Season = %v, want %d", history[0].Identity.Season, seasonNumber)
|
|
}
|
|
if history[0].Identity.Episode == nil || *history[0].Identity.Episode != episodeNumber {
|
|
t.Errorf("ListHistory Identity.Episode = %v, want %d", history[0].Identity.Episode, episodeNumber)
|
|
}
|
|
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:01:00Z",
|
|
Source: userstore.WatchHistorySourceManual,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(manual): %v", err)
|
|
}
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:02:00Z",
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(playback): %v", err)
|
|
}
|
|
if err := store.DeleteHistoryBySource(ctx, "p1", []string{"movie-1"}, userstore.WatchHistorySourceManual); err != nil {
|
|
t.Fatalf("DeleteHistoryBySource(manual): %v", err)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after delete): %v", err)
|
|
}
|
|
if len(history) != 2 {
|
|
t.Fatalf("ListHistory(after delete) returned %d, want 2", len(history))
|
|
}
|
|
for _, entry := range history {
|
|
if entry.Source == userstore.WatchHistorySourceManual {
|
|
t.Fatalf("DeleteHistoryBySource removed wrong rows: %+v", history)
|
|
}
|
|
}
|
|
|
|
removedAt := time.Date(2026, 3, 23, 12, 3, 0, 0, time.UTC)
|
|
if err := store.RemoveHistoryItems(ctx, "p1", []string{"movie-1"}, removedAt); err != nil {
|
|
t.Fatalf("RemoveHistoryItems: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after remove): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Fatalf("GetProgress(after remove) returned %+v, want nil", *wp)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after remove): %v", err)
|
|
}
|
|
if len(history) != 0 {
|
|
t.Fatalf("ListHistory(after remove) returned %d, want 0", len(history))
|
|
}
|
|
|
|
if err := store.SetProgressAt(
|
|
ctx,
|
|
"p1",
|
|
"movie-1",
|
|
7200,
|
|
7200,
|
|
true,
|
|
time.Date(2026, 3, 23, 12, 2, 30, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("SetProgressAt(hidden import): %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after hidden import): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Fatalf("GetProgress(after hidden import) returned %+v, want nil", *wp)
|
|
}
|
|
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:02:30Z",
|
|
Source: userstore.WatchHistorySourceImport,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(hidden import): %v", err)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after hidden import): %v", err)
|
|
}
|
|
if len(history) != 0 {
|
|
t.Fatalf("ListHistory(after hidden import) returned %d, want 0", len(history))
|
|
}
|
|
|
|
if err := store.SetProgressAt(
|
|
ctx,
|
|
"p1",
|
|
"movie-1",
|
|
7200,
|
|
7200,
|
|
true,
|
|
time.Date(2026, 3, 23, 12, 4, 0, 0, time.UTC),
|
|
); err != nil {
|
|
t.Fatalf("SetProgressAt(after remove): %v", err)
|
|
}
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-1",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:04:00Z",
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(after remove): %v", err)
|
|
}
|
|
history, err = store.ListHistory(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory(after new watch): %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("ListHistory(after new watch) returned %d, want 1", len(history))
|
|
}
|
|
if history[0].WatchedAt != "2026-03-23T12:04:00Z" {
|
|
t.Fatalf("ListHistory(after new watch) watched_at = %q, want 2026-03-23T12:04:00Z", history[0].WatchedAt)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after new watch): %v", err)
|
|
}
|
|
if wp == nil || !wp.Completed {
|
|
t.Fatalf("GetProgress(after new watch) = %+v, want completed progress", wp)
|
|
}
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p2", Name: "Other"}); err != nil {
|
|
t.Fatalf("CreateProfile(p2): %v", err)
|
|
}
|
|
for _, entry := range []userstore.WatchHistoryEntry{
|
|
{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-history-only",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:05:00Z",
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-hidden-history",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:05:00Z",
|
|
Source: userstore.WatchHistorySourceSimkl,
|
|
},
|
|
{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-future-hidden",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:10:00Z",
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "p2",
|
|
MediaItemID: "movie-other-profile",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:05:00Z",
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-incomplete-history",
|
|
DurationSeconds: 7200,
|
|
Completed: false,
|
|
WatchedAt: "2026-03-23T12:05:00Z",
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
} {
|
|
if err := store.AddHistory(ctx, entry); err != nil {
|
|
t.Fatalf("AddHistory(%s): %v", entry.MediaItemID, err)
|
|
}
|
|
}
|
|
if err := store.RemoveHistoryItems(ctx, "p1", []string{"movie-hidden-history"}, time.Date(2026, 3, 23, 12, 6, 0, 0, time.UTC)); err != nil {
|
|
t.Fatalf("RemoveHistoryItems(movie-hidden-history): %v", err)
|
|
}
|
|
if err := store.RemoveHistoryItems(ctx, "p1", []string{"movie-future-hidden"}, time.Date(2026, 3, 23, 12, 6, 0, 0, time.UTC)); err != nil {
|
|
t.Fatalf("RemoveHistoryItems(movie-future-hidden): %v", err)
|
|
}
|
|
completedItems, err := store.ListCompletedHistoryItems(ctx, userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "p1",
|
|
MediaItemIDs: []string{
|
|
"movie-1",
|
|
"movie-history-only",
|
|
"movie-hidden-history",
|
|
"movie-future-hidden",
|
|
"movie-other-profile",
|
|
"movie-incomplete-history",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems: %v", err)
|
|
}
|
|
completedSet := map[string]userstore.CompletedHistoryItem{}
|
|
for _, item := range completedItems {
|
|
completedSet[item.MediaItemID] = item
|
|
}
|
|
if completedSet["movie-1"].MediaItemID == "" || completedSet["movie-history-only"].MediaItemID == "" {
|
|
t.Fatalf("ListCompletedHistoryItems = %v, want movie-1 and movie-history-only", completedItems)
|
|
}
|
|
for _, id := range []string{"movie-hidden-history", "movie-future-hidden", "movie-other-profile", "movie-incomplete-history"} {
|
|
if completedSet[id].MediaItemID != "" {
|
|
t.Fatalf("ListCompletedHistoryItems included %s: %v", id, completedItems)
|
|
}
|
|
}
|
|
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
|
ProfileID: "p1",
|
|
MediaItemID: "movie-future-hidden",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
WatchedAt: "2026-03-23T12:11:00Z",
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(movie-future-hidden newer): %v", err)
|
|
}
|
|
completedItems, err = store.ListCompletedHistoryItems(ctx, userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "p1",
|
|
MediaItemIDs: []string{"movie-future-hidden"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems(movie-future-hidden newer): %v", err)
|
|
}
|
|
if len(completedItems) != 1 || completedItems[0].MediaItemID != "movie-future-hidden" || completedItems[0].WatchedAt != "2026-03-23T12:11:00Z" {
|
|
t.Fatalf("ListCompletedHistoryItems(movie-future-hidden newer) = %v, want movie-future-hidden with latest watched_at", completedItems)
|
|
}
|
|
traktItems, err := store.ListCompletedHistoryItems(ctx, userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "p1",
|
|
MediaItemIDs: []string{"movie-1", "movie-history-only"},
|
|
IncludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourcePlayback, userstore.WatchHistorySourceTrakt},
|
|
ExcludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourcePlayback},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems(source filters): %v", err)
|
|
}
|
|
if len(traktItems) != 1 || traktItems[0].MediaItemID != "movie-history-only" {
|
|
t.Fatalf("ListCompletedHistoryItems(source filters) = %v, want [movie-history-only]", traktItems)
|
|
}
|
|
|
|
// Manual watched state helpers.
|
|
if err := store.MarkWatched(ctx, "p1", "movie-3", 5400); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-3")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after MarkWatched): %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress(after MarkWatched) returned nil")
|
|
}
|
|
if !wp.Completed {
|
|
t.Error("MarkWatched should create a completed progress row")
|
|
}
|
|
if wp.PositionSeconds != 0 || wp.DurationSeconds != 5400 {
|
|
t.Errorf("MarkWatched stored position=%v duration=%v, want 0/5400", wp.PositionSeconds, wp.DurationSeconds)
|
|
}
|
|
|
|
if err := store.ClearProgress(ctx, "p1", "movie-3"); err != nil {
|
|
t.Fatalf("ClearProgress: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-3")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after ClearProgress): %v", err)
|
|
}
|
|
if wp != nil {
|
|
t.Error("ClearProgress should remove the progress row")
|
|
}
|
|
|
|
progressMap, err := store.ListProgressByMediaItems(ctx, "p1", []string{"movie-1", "movie-2", "missing"})
|
|
if err != nil {
|
|
t.Fatalf("ListProgressByMediaItems: %v", err)
|
|
}
|
|
if len(progressMap) != 2 {
|
|
t.Fatalf("ListProgressByMediaItems returned %d items, want 2", len(progressMap))
|
|
}
|
|
if progressMap["movie-1"].MediaItemID != "movie-1" {
|
|
t.Errorf("ListProgressByMediaItems movie-1 = %+v, want media item movie-1", progressMap["movie-1"])
|
|
}
|
|
if !progressMap["movie-2"].Completed {
|
|
t.Errorf("ListProgressByMediaItems movie-2 completed = %v, want true", progressMap["movie-2"].Completed)
|
|
}
|
|
|
|
emptyProgressMap, err := store.ListProgressByMediaItems(ctx, "p1", nil)
|
|
if err != nil {
|
|
t.Fatalf("ListProgressByMediaItems(empty): %v", err)
|
|
}
|
|
if len(emptyProgressMap) != 0 {
|
|
t.Errorf("ListProgressByMediaItems(empty) returned %d items, want 0", len(emptyProgressMap))
|
|
}
|
|
|
|
// Batch helpers — exercise the unnest()/IN(...) paths used by the
|
|
// jellycompat series mark-played/unplayed handlers.
|
|
batchAt := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
|
if err := store.MarkProgressBatch(ctx, "p1", nil, batchAt); err != nil {
|
|
t.Fatalf("MarkProgressBatch(empty): %v", err)
|
|
}
|
|
if err := store.MarkProgressBatch(ctx, "p1", []string{"ep-a", "ep-b", "ep-c"}, batchAt); err != nil {
|
|
t.Fatalf("MarkProgressBatch: %v", err)
|
|
}
|
|
for _, id := range []string{"ep-a", "ep-b", "ep-c"} {
|
|
wp, err := store.GetProgress(ctx, "p1", id)
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(%s): %v", id, err)
|
|
}
|
|
if wp == nil || !wp.Completed {
|
|
t.Fatalf("GetProgress(%s) = %+v, want completed row", id, wp)
|
|
}
|
|
}
|
|
|
|
if err := store.ClearProgressBatch(ctx, "p1", nil, batchAt); err != nil {
|
|
t.Fatalf("ClearProgressBatch(empty): %v", err)
|
|
}
|
|
if err := store.ClearProgressBatch(ctx, "p1", []string{"ep-a", "ep-b"}, batchAt); err != nil {
|
|
t.Fatalf("ClearProgressBatch: %v", err)
|
|
}
|
|
for _, id := range []string{"ep-a", "ep-b"} {
|
|
wp, err := store.GetProgress(ctx, "p1", id)
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(after ClearProgressBatch %s): %v", id, err)
|
|
}
|
|
if wp == nil || wp.Completed {
|
|
t.Fatalf("GetProgress(after ClearProgressBatch %s) = %+v, want completed=false row", id, wp)
|
|
}
|
|
if wp.PositionSeconds != 0 {
|
|
t.Fatalf("GetProgress(after ClearProgressBatch %s) position = %v, want 0", id, wp.PositionSeconds)
|
|
}
|
|
}
|
|
wpC, err := store.GetProgress(ctx, "p1", "ep-c")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(ep-c, untouched): %v", err)
|
|
}
|
|
if wpC == nil || !wpC.Completed {
|
|
t.Fatalf("GetProgress(ep-c, untouched) = %+v, want still completed", wpC)
|
|
}
|
|
|
|
// Defense-in-depth: empty/whitespace/duplicate IDs must be compacted out
|
|
// before they hit SQL — otherwise pgstore would insert a literal '' row
|
|
// into user_watch_progress and userdb would bind '' into the IN clause.
|
|
if err := store.MarkProgressBatch(ctx, "p1", []string{"only-a", "", "only-a", " "}, batchAt); err != nil {
|
|
t.Fatalf("MarkProgressBatch(dirty): %v", err)
|
|
}
|
|
wpA, err := store.GetProgress(ctx, "p1", "only-a")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(only-a after dirty mark): %v", err)
|
|
}
|
|
if wpA == nil || !wpA.Completed {
|
|
t.Fatalf("GetProgress(only-a after dirty mark) = %+v, want completed", wpA)
|
|
}
|
|
if wpEmpty, err := store.GetProgress(ctx, "p1", ""); err != nil {
|
|
t.Fatalf("GetProgress(empty after dirty mark): %v", err)
|
|
} else if wpEmpty != nil {
|
|
t.Fatalf("GetProgress(empty after dirty mark) = %+v, want nil — empty-string IDs must be compacted", wpEmpty)
|
|
}
|
|
if wpWS, err := store.GetProgress(ctx, "p1", " "); err != nil {
|
|
t.Fatalf("GetProgress(whitespace after dirty mark): %v", err)
|
|
} else if wpWS != nil {
|
|
t.Fatalf("GetProgress(whitespace after dirty mark) = %+v, want nil — whitespace IDs must be compacted", wpWS)
|
|
}
|
|
|
|
// Same coverage for ClearProgressBatch — passing dirty input must not
|
|
// touch a literal '' row (and must not error).
|
|
if err := store.ClearProgressBatch(ctx, "p1", []string{"only-a", "", "only-a", " "}, batchAt); err != nil {
|
|
t.Fatalf("ClearProgressBatch(dirty): %v", err)
|
|
}
|
|
wpA2, err := store.GetProgress(ctx, "p1", "only-a")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(only-a after dirty clear): %v", err)
|
|
}
|
|
if wpA2 == nil || wpA2.Completed {
|
|
t.Fatalf("GetProgress(only-a after dirty clear) = %+v, want completed=false", wpA2)
|
|
}
|
|
}
|
|
|
|
func testHomeDismissals(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
continueWatching := userstore.HomeItemDismissal{
|
|
ProfileID: "p1",
|
|
Surface: userstore.HomeSurfaceContinueWatching,
|
|
MediaItemID: "ep-1",
|
|
ProgressUpdatedAt: stringPtr("2026-03-22T18:10:00Z"),
|
|
DismissedAt: "2026-03-22T18:11:00Z",
|
|
}
|
|
if err := store.UpsertHomeDismissal(ctx, continueWatching); err != nil {
|
|
t.Fatalf("UpsertHomeDismissal(continue_watching): %v", err)
|
|
}
|
|
|
|
nextUp := userstore.HomeItemDismissal{
|
|
ProfileID: "p1",
|
|
Surface: userstore.HomeSurfaceNextUp,
|
|
MediaItemID: "ep-2",
|
|
SeriesID: stringPtr("series-1"),
|
|
DismissedAt: "2026-03-22T18:12:00Z",
|
|
}
|
|
if err := store.UpsertHomeDismissal(ctx, nextUp); err != nil {
|
|
t.Fatalf("UpsertHomeDismissal(next_up): %v", err)
|
|
}
|
|
|
|
continueDismissals, err := store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceContinueWatching)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(continue_watching): %v", err)
|
|
}
|
|
if len(continueDismissals) != 1 {
|
|
t.Fatalf("continue watching dismissals = %d, want 1", len(continueDismissals))
|
|
}
|
|
if continueDismissals[0].MediaItemID != "ep-1" {
|
|
t.Fatalf("continue watching dismissal media_item_id = %q, want ep-1", continueDismissals[0].MediaItemID)
|
|
}
|
|
if continueDismissals[0].ProgressUpdatedAt == nil || *continueDismissals[0].ProgressUpdatedAt != "2026-03-22T18:10:00Z" {
|
|
t.Fatalf("continue watching dismissal progress_updated_at = %v, want 2026-03-22T18:10:00Z", continueDismissals[0].ProgressUpdatedAt)
|
|
}
|
|
|
|
nextUpDismissals, err := store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceNextUp)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(next_up): %v", err)
|
|
}
|
|
if len(nextUpDismissals) != 1 {
|
|
t.Fatalf("next up dismissals = %d, want 1", len(nextUpDismissals))
|
|
}
|
|
if nextUpDismissals[0].SeriesID == nil || *nextUpDismissals[0].SeriesID != "series-1" {
|
|
t.Fatalf("next up dismissal series_id = %v, want series-1", nextUpDismissals[0].SeriesID)
|
|
}
|
|
|
|
updated := continueWatching
|
|
updated.ProgressUpdatedAt = stringPtr("2026-03-23T00:00:00Z")
|
|
updated.DismissedAt = "2026-03-23T00:01:00Z"
|
|
if err := store.UpsertHomeDismissal(ctx, updated); err != nil {
|
|
t.Fatalf("UpsertHomeDismissal(update): %v", err)
|
|
}
|
|
|
|
continueDismissals, err = store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceContinueWatching)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(after update): %v", err)
|
|
}
|
|
if len(continueDismissals) != 1 {
|
|
t.Fatalf("continue watching dismissals after update = %d, want 1", len(continueDismissals))
|
|
}
|
|
if continueDismissals[0].ProgressUpdatedAt == nil || *continueDismissals[0].ProgressUpdatedAt != "2026-03-23T00:00:00Z" {
|
|
t.Fatalf("continue watching dismissal progress_updated_at after update = %v, want 2026-03-23T00:00:00Z", continueDismissals[0].ProgressUpdatedAt)
|
|
}
|
|
|
|
if err := store.DeleteHomeDismissal(ctx, "p1", userstore.HomeSurfaceContinueWatching, "ep-1"); err != nil {
|
|
t.Fatalf("DeleteHomeDismissal: %v", err)
|
|
}
|
|
|
|
continueDismissals, err = store.ListHomeDismissals(ctx, "p1", userstore.HomeSurfaceContinueWatching)
|
|
if err != nil {
|
|
t.Fatalf("ListHomeDismissals(after delete): %v", err)
|
|
}
|
|
if len(continueDismissals) != 0 {
|
|
t.Fatalf("continue watching dismissals after delete = %d, want 0", len(continueDismissals))
|
|
}
|
|
}
|
|
|
|
func stringPtr(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
func cursorInt(t *testing.T, cursor string) int64 {
|
|
t.Helper()
|
|
n, err := strconv.ParseInt(cursor, 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("cursor %q is not a server token: %v", cursor, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// testProgressSince exercises invariant 1: cross-device delta delivery is driven
|
|
// only by the server-assigned synced_seq cursor, and a write that loses the
|
|
// event_at LWW (which is exactly what a clamped future-dated client write
|
|
// becomes — older than a later real write) never advances the cursor.
|
|
func testProgressSince(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
base := time.Now().UTC().Add(-time.Hour)
|
|
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m1", 100, 1000, false, base); err != nil {
|
|
t.Fatalf("write m1: %v", err)
|
|
}
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m2", 200, 1000, false, base.Add(time.Minute)); err != nil {
|
|
t.Fatalf("write m2: %v", err)
|
|
}
|
|
|
|
// Full delta from an empty cursor returns both rows + a non-zero server token.
|
|
all, cursor, err := store.ListProgressSince(ctx, "p1", "")
|
|
if err != nil {
|
|
t.Fatalf("ListProgressSince(empty): %v", err)
|
|
}
|
|
if len(all) != 2 {
|
|
t.Fatalf("full delta = %d rows, want 2", len(all))
|
|
}
|
|
if cursorInt(t, cursor) <= 0 {
|
|
t.Fatalf("next cursor = %q, want a positive server token", cursor)
|
|
}
|
|
|
|
// No further changes → empty delta, cursor unchanged.
|
|
none, cursor2, err := store.ListProgressSince(ctx, "p1", cursor)
|
|
if err != nil {
|
|
t.Fatalf("ListProgressSince(cursor): %v", err)
|
|
}
|
|
if len(none) != 0 {
|
|
t.Fatalf("empty delta = %d rows, want 0", len(none))
|
|
}
|
|
if cursorInt(t, cursor2) != cursorInt(t, cursor) {
|
|
t.Fatalf("cursor advanced with no change: %q -> %q", cursor, cursor2)
|
|
}
|
|
|
|
// A new winning write appears in the delta and advances the cursor.
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m3", 300, 1000, false, base.Add(2*time.Minute)); err != nil {
|
|
t.Fatalf("write m3: %v", err)
|
|
}
|
|
delta, cursor3, err := store.ListProgressSince(ctx, "p1", cursor)
|
|
if err != nil {
|
|
t.Fatalf("ListProgressSince(after new): %v", err)
|
|
}
|
|
if len(delta) != 1 || delta[0].MediaItemID != "m3" {
|
|
t.Fatalf("delta = %+v, want [m3]", delta)
|
|
}
|
|
if cursorInt(t, cursor3) <= cursorInt(t, cursor) {
|
|
t.Fatalf("cursor did not advance: %q -> %q", cursor, cursor3)
|
|
}
|
|
|
|
// invariant 1: a stale write (older event_at) loses LWW AND never advances
|
|
// the cursor — the same fate a clamped future-dated client write meets.
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m3", 999, 1000, false, base); err != nil {
|
|
t.Fatalf("stale write m3: %v", err)
|
|
}
|
|
got, err := store.GetProgress(ctx, "p1", "m3")
|
|
if err != nil || got == nil || got.PositionSeconds != 300 {
|
|
t.Fatalf("stale write won LWW: %+v (%v), want position 300", got, err)
|
|
}
|
|
stale, cursorStale, err := store.ListProgressSince(ctx, "p1", cursor3)
|
|
if err != nil {
|
|
t.Fatalf("ListProgressSince(after stale): %v", err)
|
|
}
|
|
if len(stale) != 0 {
|
|
t.Fatalf("stale write delivered a delta: %+v", stale)
|
|
}
|
|
if cursorInt(t, cursorStale) != cursorInt(t, cursor3) {
|
|
t.Fatalf("stale write advanced the cursor: %q -> %q", cursor3, cursorStale)
|
|
}
|
|
}
|
|
|
|
// testOnlineWriteAdvancesEventAt locks in the LWW fix: a normal (online) write
|
|
// must advance the event_at comparison key, so a later offline replay whose
|
|
// client time predates the online write loses and cannot resurrect stale
|
|
// progress. Before the fix, online writes updated updated_at but left event_at
|
|
// frozen at the row's first write, letting almost any offline event win.
|
|
func testOnlineWriteAdvancesEventAt(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
noThreshold := userstore.ProgressThresholds{}
|
|
// SetProgress and UpdateProgress share the online-write contract: both must
|
|
// stamp event_at = now, so both are pinned here.
|
|
onlineWrites := []struct {
|
|
name string
|
|
write func(ctx context.Context, store userstore.UserStore) error
|
|
}{
|
|
{"SetProgress", func(ctx context.Context, store userstore.UserStore) error {
|
|
return store.SetProgress(ctx, "p1", "m1", 500, 1000, noThreshold)
|
|
}},
|
|
{"UpdateProgress", func(ctx context.Context, store userstore.UserStore) error {
|
|
return store.UpdateProgress(ctx, "p1", "m1", 500, 1000, noThreshold)
|
|
}},
|
|
}
|
|
for _, tc := range onlineWrites {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
base := time.Now().UTC().Add(-time.Hour)
|
|
|
|
// An offline event seeds the row with an old event_at.
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m1", 100, 1000, false, base); err != nil {
|
|
t.Fatalf("seed offline write: %v", err)
|
|
}
|
|
// A live online write (no client time) must stamp event_at = now,
|
|
// overtaking the seed's event_at.
|
|
if err := tc.write(ctx, store); err != nil {
|
|
t.Fatalf("online %s: %v", tc.name, err)
|
|
}
|
|
// A replayed offline event whose client time is newer than the seed but
|
|
// older than the online write must NOT win.
|
|
wrote, err := store.SetProgressIfNewer(ctx, "p1", "m1", 999, 1000, false, base.Add(5*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("stale offline replay: %v", err)
|
|
}
|
|
if wrote {
|
|
t.Fatalf("stale offline replay reported a write; online event_at should have won")
|
|
}
|
|
got, err := store.GetProgress(ctx, "p1", "m1")
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetProgress: %+v (%v)", got, err)
|
|
}
|
|
if got.PositionSeconds != 500 {
|
|
t.Fatalf("stale offline replay won LWW: position = %v, want 500 (online write)", got.PositionSeconds)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// testBatchWritesAdvanceEventAt extends the LWW guarantee to the batch write
|
|
// paths (jellycompat series mark-played and mark-unplayed): they advance
|
|
// updated_at without hand-setting event_at, so the stamping trigger must
|
|
// advance the LWW key for them — a queued offline event older than the batch
|
|
// write must lose. It also pins the inverse: a write that DOES set event_at
|
|
// (offline sync's clamped client event time) keeps that value rather than
|
|
// having the trigger clobber it with the server write time.
|
|
func testBatchWritesAdvanceEventAt(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
base := time.Now().UTC().Add(-time.Hour)
|
|
|
|
// MarkProgressBatch (series mark-played) must advance event_at.
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m1", 100, 1000, false, base); err != nil {
|
|
t.Fatalf("seed m1: %v", err)
|
|
}
|
|
if err := store.MarkProgressBatch(ctx, "p1", []string{"m1"}, time.Time{}); err != nil {
|
|
t.Fatalf("MarkProgressBatch: %v", err)
|
|
}
|
|
wrote, err := store.SetProgressIfNewer(ctx, "p1", "m1", 999, 1000, false, base.Add(5*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("stale replay after mark-played: %v", err)
|
|
}
|
|
if wrote {
|
|
t.Fatal("offline event older than a batch mark-played won LWW; MarkProgressBatch left event_at stale")
|
|
}
|
|
got, err := store.GetProgress(ctx, "p1", "m1")
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetProgress(m1): %+v (%v)", got, err)
|
|
}
|
|
if got.PositionSeconds != 0 || !got.Completed {
|
|
t.Fatalf("m1 = pos %v completed %v; stale replay must not disturb mark-played state", got.PositionSeconds, got.Completed)
|
|
}
|
|
|
|
// ClearProgressBatch (mark-unplayed) must advance event_at the same way.
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m2", 300, 1000, false, base); err != nil {
|
|
t.Fatalf("seed m2: %v", err)
|
|
}
|
|
if err := store.ClearProgressBatch(ctx, "p1", []string{"m2"}, time.Time{}); err != nil {
|
|
t.Fatalf("ClearProgressBatch: %v", err)
|
|
}
|
|
wrote, err = store.SetProgressIfNewer(ctx, "p1", "m2", 888, 1000, false, base.Add(5*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("stale replay after clear: %v", err)
|
|
}
|
|
if wrote {
|
|
t.Fatal("offline event older than a batch clear won LWW; ClearProgressBatch left event_at stale")
|
|
}
|
|
got, err = store.GetProgress(ctx, "p1", "m2")
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetProgress(m2): %+v (%v)", got, err)
|
|
}
|
|
if got.PositionSeconds != 0 {
|
|
t.Fatalf("m2 position = %v; stale replay resurrected a cleared resume point", got.PositionSeconds)
|
|
}
|
|
|
|
// An explicitly-set client event time survives the trigger: a strictly
|
|
// newer offline event (still far in the past) must be accepted, which can
|
|
// only happen if the stored event_at is the client time, not server now.
|
|
evt := base.Add(10 * time.Minute)
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m3", 100, 1000, false, evt); err != nil {
|
|
t.Fatalf("seed m3: %v", err)
|
|
}
|
|
if _, err := store.SetProgressIfNewer(ctx, "p1", "m3", 200, 1000, false, evt.Add(time.Minute)); err != nil {
|
|
t.Fatalf("advance m3: %v", err)
|
|
}
|
|
wrote, err = store.SetProgressIfNewer(ctx, "p1", "m3", 300, 1000, false, evt.Add(2*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("third m3 write: %v", err)
|
|
}
|
|
if !wrote {
|
|
t.Fatal("a strictly newer offline event was rejected; the trigger clobbered an explicitly-set event_at")
|
|
}
|
|
}
|
|
|
|
func testFavorites(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Add
|
|
if err := store.AddFavorite(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("AddFavorite: %v", err)
|
|
}
|
|
if err := store.AddFavorite(ctx, "p1", "item-2"); err != nil {
|
|
t.Fatalf("AddFavorite: %v", err)
|
|
}
|
|
|
|
// Add duplicate (should be no-op)
|
|
if err := store.AddFavorite(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("AddFavorite(duplicate): %v", err)
|
|
}
|
|
|
|
// Check
|
|
ok, err := store.IsFavorite(ctx, "p1", "item-1")
|
|
if err != nil {
|
|
t.Fatalf("IsFavorite: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("IsFavorite returned false, want true")
|
|
}
|
|
|
|
ok, _ = store.IsFavorite(ctx, "p1", "nonexistent")
|
|
if ok {
|
|
t.Error("IsFavorite(nonexistent) returned true")
|
|
}
|
|
|
|
// List
|
|
favs, err := store.ListFavorites(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListFavorites: %v", err)
|
|
}
|
|
if len(favs) != 2 {
|
|
t.Errorf("ListFavorites returned %d, want 2", len(favs))
|
|
}
|
|
|
|
favoriteMap, err := store.ListFavoritesByMediaItems(ctx, "p1", []string{"item-1", "item-2", "missing"})
|
|
if err != nil {
|
|
t.Fatalf("ListFavoritesByMediaItems: %v", err)
|
|
}
|
|
if len(favoriteMap) != 2 {
|
|
t.Fatalf("ListFavoritesByMediaItems returned %d items, want 2", len(favoriteMap))
|
|
}
|
|
if !favoriteMap["item-1"] || !favoriteMap["item-2"] {
|
|
t.Errorf("ListFavoritesByMediaItems returned %v, want item-1 and item-2 true", favoriteMap)
|
|
}
|
|
|
|
emptyFavoriteMap, err := store.ListFavoritesByMediaItems(ctx, "p1", nil)
|
|
if err != nil {
|
|
t.Fatalf("ListFavoritesByMediaItems(empty): %v", err)
|
|
}
|
|
if len(emptyFavoriteMap) != 0 {
|
|
t.Errorf("ListFavoritesByMediaItems(empty) returned %d items, want 0", len(emptyFavoriteMap))
|
|
}
|
|
|
|
// Remove
|
|
if err := store.RemoveFavorite(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("RemoveFavorite: %v", err)
|
|
}
|
|
ok, _ = store.IsFavorite(ctx, "p1", "item-1")
|
|
if ok {
|
|
t.Error("IsFavorite after remove returned true")
|
|
}
|
|
}
|
|
|
|
func testWatchlist(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Add
|
|
if err := store.AddToWatchlist(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("AddToWatchlist: %v", err)
|
|
}
|
|
|
|
// Check
|
|
ok, err := store.InWatchlist(ctx, "p1", "item-1")
|
|
if err != nil {
|
|
t.Fatalf("InWatchlist: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("InWatchlist returned false, want true")
|
|
}
|
|
|
|
// List
|
|
entries, err := store.ListWatchlist(ctx, "p1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlist: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Errorf("ListWatchlist returned %d, want 1", len(entries))
|
|
}
|
|
|
|
watchlistMap, err := store.ListWatchlistByMediaItems(ctx, "p1", []string{"item-1", "missing"})
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlistByMediaItems: %v", err)
|
|
}
|
|
if len(watchlistMap) != 1 {
|
|
t.Fatalf("ListWatchlistByMediaItems returned %d items, want 1", len(watchlistMap))
|
|
}
|
|
if !watchlistMap["item-1"] {
|
|
t.Errorf("ListWatchlistByMediaItems returned %v, want item-1 true", watchlistMap)
|
|
}
|
|
|
|
emptyWatchlistMap, err := store.ListWatchlistByMediaItems(ctx, "p1", nil)
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlistByMediaItems(empty): %v", err)
|
|
}
|
|
if len(emptyWatchlistMap) != 0 {
|
|
t.Errorf("ListWatchlistByMediaItems(empty) returned %d items, want 0", len(emptyWatchlistMap))
|
|
}
|
|
|
|
// Remove
|
|
if err := store.RemoveFromWatchlist(ctx, "p1", "item-1"); err != nil {
|
|
t.Fatalf("RemoveFromWatchlist: %v", err)
|
|
}
|
|
ok, _ = store.InWatchlist(ctx, "p1", "item-1")
|
|
if ok {
|
|
t.Error("InWatchlist after remove returned true")
|
|
}
|
|
}
|
|
|
|
func testCollections(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Create
|
|
coll, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{
|
|
CreatorProfileID: "p1",
|
|
Name: "My Collection",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection: %v", err)
|
|
}
|
|
if coll.Name != "My Collection" {
|
|
t.Errorf("Collection.Name = %q, want %q", coll.Name, "My Collection")
|
|
}
|
|
|
|
// Get
|
|
got, err := store.GetCollection(ctx, coll.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetCollection: %v", err)
|
|
}
|
|
if got.Name != "My Collection" {
|
|
t.Errorf("GetCollection Name = %q, want %q", got.Name, "My Collection")
|
|
}
|
|
|
|
// List
|
|
colls, err := store.ListCollections(ctx, "p1")
|
|
if err != nil {
|
|
t.Fatalf("ListCollections: %v", err)
|
|
}
|
|
if len(colls) != 1 {
|
|
t.Errorf("ListCollections returned %d, want 1", len(colls))
|
|
}
|
|
|
|
// Update
|
|
newName := "Renamed"
|
|
if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{
|
|
ID: coll.ID,
|
|
RequestProfileID: "p1",
|
|
Name: &newName,
|
|
}); err != nil {
|
|
t.Fatalf("UpdateCollection: %v", err)
|
|
}
|
|
got, _ = store.GetCollection(ctx, coll.ID)
|
|
if got.Name != "Renamed" {
|
|
t.Errorf("Updated Name = %q, want %q", got.Name, "Renamed")
|
|
}
|
|
|
|
// Add items
|
|
if err := store.AddCollectionItem(ctx, coll.ID, "item-1", 0); err != nil {
|
|
t.Fatalf("AddCollectionItem: %v", err)
|
|
}
|
|
if err := store.AddCollectionItem(ctx, coll.ID, "item-2", 1); err != nil {
|
|
t.Fatalf("AddCollectionItem: %v", err)
|
|
}
|
|
|
|
// List items
|
|
items, err := store.ListCollectionItems(ctx, coll.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListCollectionItems: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Errorf("ListCollectionItems returned %d, want 2", len(items))
|
|
}
|
|
|
|
// Remove item
|
|
if err := store.RemoveCollectionItem(ctx, coll.ID, "item-1"); err != nil {
|
|
t.Fatalf("RemoveCollectionItem: %v", err)
|
|
}
|
|
items, _ = store.ListCollectionItems(ctx, coll.ID)
|
|
if len(items) != 1 {
|
|
t.Errorf("After remove, ListCollectionItems returned %d, want 1", len(items))
|
|
}
|
|
|
|
// Delete collection (should also delete items)
|
|
if err := store.DeleteCollection(ctx, coll.ID); err != nil {
|
|
t.Fatalf("DeleteCollection: %v", err)
|
|
}
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p2", Name: "Viewer"}); err != nil {
|
|
t.Fatalf("CreateProfile(p2): %v", err)
|
|
}
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p3", Name: "Blocked"}); err != nil {
|
|
t.Fatalf("CreateProfile(p3): %v", err)
|
|
}
|
|
|
|
shared, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{
|
|
CreatorProfileID: "p1",
|
|
Name: "Family Action",
|
|
CollectionType: "smart",
|
|
IsShared: true,
|
|
AllowedProfileIDs: []string{"p1", "p2"},
|
|
QueryDefinition: `{"match":"all","groups":[]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection(shared): %v", err)
|
|
}
|
|
|
|
visible, err := store.ListCollections(ctx, "p2")
|
|
if err != nil {
|
|
t.Fatalf("ListCollections(p2): %v", err)
|
|
}
|
|
if len(visible) != 1 || visible[0].ID != shared.ID {
|
|
t.Fatalf("ListCollections(p2) = %+v, want shared collection", visible)
|
|
}
|
|
|
|
blocked, err := store.ListCollections(ctx, "p3")
|
|
if err != nil {
|
|
t.Fatalf("ListCollections(p3): %v", err)
|
|
}
|
|
if len(blocked) != 0 {
|
|
t.Fatalf("ListCollections(p3) returned %d collections, want 0", len(blocked))
|
|
}
|
|
|
|
rejectedName := "Not Allowed"
|
|
if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{
|
|
ID: shared.ID,
|
|
RequestProfileID: "p2",
|
|
Name: &rejectedName,
|
|
}); err == nil {
|
|
t.Fatal("expected creator-only UpdateCollection rejection")
|
|
}
|
|
}
|
|
|
|
func testSettings(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Get non-existent
|
|
val, err := store.GetSetting(ctx, "theme")
|
|
if err != nil {
|
|
t.Fatalf("GetSetting(nonexistent): %v", err)
|
|
}
|
|
if val != "" {
|
|
t.Errorf("GetSetting(nonexistent) = %q, want empty", val)
|
|
}
|
|
|
|
// Set
|
|
if err := store.SetSetting(ctx, "theme", "dark"); err != nil {
|
|
t.Fatalf("SetSetting: %v", err)
|
|
}
|
|
val, err = store.GetSetting(ctx, "theme")
|
|
if err != nil {
|
|
t.Fatalf("GetSetting: %v", err)
|
|
}
|
|
if val != "dark" {
|
|
t.Errorf("GetSetting = %q, want %q", val, "dark")
|
|
}
|
|
|
|
// Update (upsert)
|
|
if err := store.SetSetting(ctx, "theme", "light"); err != nil {
|
|
t.Fatalf("SetSetting(update): %v", err)
|
|
}
|
|
val, _ = store.GetSetting(ctx, "theme")
|
|
if val != "light" {
|
|
t.Errorf("GetSetting after update = %q, want %q", val, "light")
|
|
}
|
|
|
|
// List
|
|
if err := store.SetSetting(ctx, "lang", "en"); err != nil {
|
|
t.Fatalf("SetSetting(lang): %v", err)
|
|
}
|
|
entries, err := store.ListSettings(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListSettings: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("ListSettings returned %d, want 2", len(entries))
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteSetting(ctx, "theme"); err != nil {
|
|
t.Fatalf("DeleteSetting: %v", err)
|
|
}
|
|
val, _ = store.GetSetting(ctx, "theme")
|
|
if val != "" {
|
|
t.Errorf("GetSetting after delete = %q, want empty", val)
|
|
}
|
|
|
|
deviceSetting, err := store.GetDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting(nonexistent): %v", err)
|
|
}
|
|
if deviceSetting != nil {
|
|
t.Fatalf("GetDeviceSetting(nonexistent) = %#v, want nil", deviceSetting)
|
|
}
|
|
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "apple-tv",
|
|
DeviceName: "Living Room",
|
|
DevicePlatform: "tvOS",
|
|
Key: "subtitle_appearance",
|
|
Value: `{"fontSize":"large"}`,
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting: %v", err)
|
|
}
|
|
deviceSetting, err = store.GetDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting: %v", err)
|
|
}
|
|
if deviceSetting == nil || deviceSetting.Value != `{"fontSize":"large"}` || deviceSetting.DeviceName != "Living Room" {
|
|
t.Fatalf("GetDeviceSetting = %#v, want stored override", deviceSetting)
|
|
}
|
|
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "iphone",
|
|
DeviceName: "iPhone",
|
|
DevicePlatform: "iOS",
|
|
Key: "subtitle_appearance",
|
|
Value: `{"fontSize":"small"}`,
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting(second): %v", err)
|
|
}
|
|
deviceSettings, err := store.ListDeviceSettings(ctx, "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("ListDeviceSettings: %v", err)
|
|
}
|
|
if len(deviceSettings) != 2 {
|
|
t.Fatalf("ListDeviceSettings returned %d, want 2", len(deviceSettings))
|
|
}
|
|
allDeviceSettings, err := store.ListAllDeviceSettings(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListAllDeviceSettings: %v", err)
|
|
}
|
|
if len(allDeviceSettings) != 2 {
|
|
t.Fatalf("ListAllDeviceSettings returned %d, want 2", len(allDeviceSettings))
|
|
}
|
|
|
|
if err := store.DeleteDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance"); err != nil {
|
|
t.Fatalf("DeleteDeviceSetting: %v", err)
|
|
}
|
|
deviceSetting, err = store.GetDeviceSetting(ctx, "p1", "apple-tv", "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting after delete: %v", err)
|
|
}
|
|
if deviceSetting != nil {
|
|
t.Fatalf("GetDeviceSetting after delete = %#v, want nil", deviceSetting)
|
|
}
|
|
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "apple-tv",
|
|
Key: "player.playback_speed",
|
|
Value: "1.25",
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting(third): %v", err)
|
|
}
|
|
if err := store.SetDeviceSetting(ctx, userstore.DeviceSettingEntry{
|
|
ProfileID: "p1",
|
|
DeviceID: "apple-tv",
|
|
Key: "player.audio_sync_ms",
|
|
Value: "120",
|
|
}); err != nil {
|
|
t.Fatalf("SetDeviceSetting(fourth): %v", err)
|
|
}
|
|
if err := store.DeleteAllDeviceSettings(ctx, "p1", "apple-tv"); err != nil {
|
|
t.Fatalf("DeleteAllDeviceSettings: %v", err)
|
|
}
|
|
deviceSetting, err = store.GetDeviceSetting(ctx, "p1", "apple-tv", "player.playback_speed")
|
|
if err != nil {
|
|
t.Fatalf("GetDeviceSetting after delete-all-device: %v", err)
|
|
}
|
|
if deviceSetting != nil {
|
|
t.Fatalf("GetDeviceSetting after DeleteAllDeviceSettings = %#v, want nil", deviceSetting)
|
|
}
|
|
|
|
if err := store.DeleteDeviceSettingsByKey(ctx, "subtitle_appearance"); err != nil {
|
|
t.Fatalf("DeleteDeviceSettingsByKey: %v", err)
|
|
}
|
|
deviceSettings, err = store.ListDeviceSettings(ctx, "subtitle_appearance")
|
|
if err != nil {
|
|
t.Fatalf("ListDeviceSettings after delete all: %v", err)
|
|
}
|
|
if len(deviceSettings) != 0 {
|
|
t.Fatalf("ListDeviceSettings after delete all returned %d, want 0", len(deviceSettings))
|
|
}
|
|
}
|
|
|
|
func testSubtitlePreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Get non-existent
|
|
pref, err := store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetSubtitlePreference(nonexistent): %v", err)
|
|
}
|
|
if pref != nil {
|
|
t.Error("GetSubtitlePreference(nonexistent) returned non-nil")
|
|
}
|
|
|
|
// Set
|
|
now := "2024-01-01T00:00:00Z"
|
|
if err := store.SetSubtitlePreference(ctx, userstore.SubtitlePreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
SubtitleLanguage: "ja",
|
|
SubtitleTrackIndex: 2,
|
|
ExternalSubtitlePath: "/subs/ep1.srt",
|
|
SubtitleMode: "off",
|
|
ShowForcedSubtitles: true,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetSubtitlePreference: %v", err)
|
|
}
|
|
|
|
// Get
|
|
pref, err = store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetSubtitlePreference: %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetSubtitlePreference returned nil")
|
|
}
|
|
if pref.SubtitleLanguage != "ja" {
|
|
t.Errorf("SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "ja")
|
|
}
|
|
if pref.SubtitleTrackIndex != 2 {
|
|
t.Errorf("SubtitleTrackIndex = %d, want 2", pref.SubtitleTrackIndex)
|
|
}
|
|
if !pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("ShowForcedSubtitles = (%v, %v), want (true, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
// Update (upsert)
|
|
if err := store.SetSubtitlePreference(ctx, userstore.SubtitlePreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
SubtitleLanguage: "en",
|
|
SubtitleMode: "auto",
|
|
ShowForcedSubtitles: false,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetSubtitlePreference(update): %v", err)
|
|
}
|
|
pref, _ = store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if pref.SubtitleLanguage != "en" {
|
|
t.Errorf("Updated SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "en")
|
|
}
|
|
if pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("Updated ShowForcedSubtitles = (%v, %v), want (false, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteSubtitlePreference(ctx, "p1", "series-1"); err != nil {
|
|
t.Fatalf("DeleteSubtitlePreference: %v", err)
|
|
}
|
|
pref, _ = store.GetSubtitlePreference(ctx, "p1", "series-1")
|
|
if pref != nil {
|
|
t.Error("GetSubtitlePreference after delete returned non-nil")
|
|
}
|
|
}
|
|
|
|
func testAudioPreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
// Create required profile first.
|
|
if err := store.CreateProfile(ctx, userstore.Profile{
|
|
ID: "p1", Name: "Test", CreatedAt: "2024-01-01T00:00:00Z", UpdatedAt: "2024-01-01T00:00:00Z",
|
|
}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
// Get non-existent
|
|
ap, err := store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetAudioPreference(nonexistent): %v", err)
|
|
}
|
|
if ap != nil {
|
|
t.Error("GetAudioPreference(nonexistent) returned non-nil")
|
|
}
|
|
|
|
// Set
|
|
now := "2024-01-01T00:00:00Z"
|
|
if err := store.SetAudioPreference(ctx, userstore.AudioPreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
AudioTrackIndex: 2,
|
|
AudioLanguage: "ja",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetAudioPreference: %v", err)
|
|
}
|
|
|
|
// Get
|
|
ap, err = store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if err != nil {
|
|
t.Fatalf("GetAudioPreference: %v", err)
|
|
}
|
|
if ap == nil {
|
|
t.Fatal("GetAudioPreference returned nil")
|
|
}
|
|
if ap.AudioLanguage != "ja" {
|
|
t.Errorf("AudioLanguage = %q, want %q", ap.AudioLanguage, "ja")
|
|
}
|
|
if ap.AudioTrackIndex != 2 {
|
|
t.Errorf("AudioTrackIndex = %d, want %d", ap.AudioTrackIndex, 2)
|
|
}
|
|
|
|
// Update (upsert)
|
|
if err := store.SetAudioPreference(ctx, userstore.AudioPreference{
|
|
ProfileID: "p1",
|
|
SeriesID: "series-1",
|
|
AudioTrackIndex: 0,
|
|
AudioLanguage: "en",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("SetAudioPreference(update): %v", err)
|
|
}
|
|
ap, _ = store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if ap.AudioLanguage != "en" {
|
|
t.Errorf("Updated AudioLanguage = %q, want %q", ap.AudioLanguage, "en")
|
|
}
|
|
if ap.AudioTrackIndex != 0 {
|
|
t.Errorf("Updated AudioTrackIndex = %d, want %d", ap.AudioTrackIndex, 0)
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteAudioPreference(ctx, "p1", "series-1"); err != nil {
|
|
t.Fatalf("DeleteAudioPreference: %v", err)
|
|
}
|
|
ap, _ = store.GetAudioPreference(ctx, "p1", "series-1")
|
|
if ap != nil {
|
|
t.Error("GetAudioPreference after delete returned non-nil")
|
|
}
|
|
}
|
|
|
|
func testLibraryPlaybackPreferences(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
pref, err := store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(nonexistent): %v", err)
|
|
}
|
|
if pref != nil {
|
|
t.Error("GetLibraryPlaybackPreference(nonexistent) returned non-nil")
|
|
}
|
|
|
|
now := "2024-01-01T00:00:00Z"
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 1,
|
|
AudioLanguage: "ja",
|
|
SubtitleLanguage: "en",
|
|
SubtitleMode: "always",
|
|
ShowForcedSubtitles: true,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference: %v", err)
|
|
}
|
|
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference: %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetLibraryPlaybackPreference returned nil")
|
|
}
|
|
if pref.AudioLanguage != "ja" {
|
|
t.Errorf("AudioLanguage = %q, want %q", pref.AudioLanguage, "ja")
|
|
}
|
|
if pref.SubtitleLanguage != "en" {
|
|
t.Errorf("SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "en")
|
|
}
|
|
if pref.SubtitleMode != "always" {
|
|
t.Errorf("SubtitleMode = %q, want %q", pref.SubtitleMode, "always")
|
|
}
|
|
if !pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("ShowForcedSubtitles = (%v, %v), want (true, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 1,
|
|
AudioLanguage: "en",
|
|
SubtitleLanguage: "es",
|
|
SubtitleMode: "auto",
|
|
ShowForcedSubtitles: false,
|
|
HasShowForcedSubtitles: true,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(update): %v", err)
|
|
}
|
|
pref, _ = store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if pref.AudioLanguage != "en" {
|
|
t.Errorf("Updated AudioLanguage = %q, want %q", pref.AudioLanguage, "en")
|
|
}
|
|
if pref.SubtitleLanguage != "es" {
|
|
t.Errorf("Updated SubtitleLanguage = %q, want %q", pref.SubtitleLanguage, "es")
|
|
}
|
|
if pref.SubtitleMode != "auto" {
|
|
t.Errorf("Updated SubtitleMode = %q, want %q", pref.SubtitleMode, "auto")
|
|
}
|
|
if pref.ShowForcedSubtitles || !pref.HasShowForcedSubtitles {
|
|
t.Errorf("Updated ShowForcedSubtitles = (%v, %v), want (false, true)", pref.ShowForcedSubtitles, pref.HasShowForcedSubtitles)
|
|
}
|
|
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 2,
|
|
AudioLanguage: "de",
|
|
SubtitleLanguage: "fr",
|
|
SubtitleMode: "off",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(second): %v", err)
|
|
}
|
|
|
|
list, err := store.ListLibraryPlaybackPreferences(ctx, "p1")
|
|
if err != nil {
|
|
t.Fatalf("ListLibraryPlaybackPreferences: %v", err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("ListLibraryPlaybackPreferences returned %d items, want 2", len(list))
|
|
}
|
|
|
|
if err := store.DeleteLibraryPlaybackPreference(ctx, "p1", 1); err != nil {
|
|
t.Fatalf("DeleteLibraryPlaybackPreference: %v", err)
|
|
}
|
|
pref, _ = store.GetLibraryPlaybackPreference(ctx, "p1", 1)
|
|
if pref != nil {
|
|
t.Error("GetLibraryPlaybackPreference after delete returned non-nil")
|
|
}
|
|
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p1",
|
|
LibraryID: 2,
|
|
AudioLanguage: "de",
|
|
SubtitleLanguage: "fr",
|
|
SubtitleMode: "off",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(lifecycle): %v", err)
|
|
}
|
|
if err := store.DeleteProfile(ctx, "p1"); err != nil {
|
|
t.Fatalf("DeleteProfile: %v", err)
|
|
}
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Recreated"}); err != nil {
|
|
t.Fatalf("CreateProfile(recreate): %v", err)
|
|
}
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p1", 2)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(recreated profile): %v", err)
|
|
}
|
|
if pref != nil {
|
|
t.Fatal("GetLibraryPlaybackPreference after profile recreate returned stale preference")
|
|
}
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p2", Name: "Other"}); err != nil {
|
|
t.Fatalf("CreateProfile(p2): %v", err)
|
|
}
|
|
if err := store.UpsertLibraryPlaybackPreference(ctx, userstore.LibraryPlaybackPreference{
|
|
ProfileID: "p2",
|
|
LibraryID: 1,
|
|
AudioLanguage: "it",
|
|
SubtitleLanguage: "pt",
|
|
SubtitleMode: "auto",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("UpsertLibraryPlaybackPreference(p2): %v", err)
|
|
}
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p2", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(p2): %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetLibraryPlaybackPreference(p2) returned nil")
|
|
}
|
|
if err := store.DeleteProfile(ctx, "p1"); err != nil {
|
|
t.Fatalf("DeleteProfile(recreated): %v", err)
|
|
}
|
|
pref, err = store.GetLibraryPlaybackPreference(ctx, "p2", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetLibraryPlaybackPreference(p2 after p1 delete): %v", err)
|
|
}
|
|
if pref == nil {
|
|
t.Fatal("GetLibraryPlaybackPreference(p2 after p1 delete) returned nil")
|
|
}
|
|
}
|
|
|
|
func testProgressHints(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
noThreshold := userstore.ProgressThresholds{}
|
|
|
|
// Must have progress row first (hints are advisory — UPDATE only).
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 500, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
|
|
// Set hints.
|
|
hints := userstore.VersionHints{
|
|
FileID: 42,
|
|
Resolution: "2160p",
|
|
HDR: true,
|
|
CodecVideo: "hevc",
|
|
}
|
|
if err := store.UpdateProgressHints(ctx, "p1", "movie-1", hints); err != nil {
|
|
t.Fatalf("UpdateProgressHints: %v", err)
|
|
}
|
|
|
|
// Verify hints are returned by GetProgress.
|
|
wp, err := store.GetProgress(ctx, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress returned nil")
|
|
}
|
|
if wp.LastFileID == nil || *wp.LastFileID != 42 {
|
|
t.Errorf("LastFileID = %v, want 42", wp.LastFileID)
|
|
}
|
|
if wp.LastResolution == nil || *wp.LastResolution != "2160p" {
|
|
t.Errorf("LastResolution = %v, want %q", wp.LastResolution, "2160p")
|
|
}
|
|
if wp.LastHDR == nil || *wp.LastHDR != true {
|
|
t.Errorf("LastHDR = %v, want true", wp.LastHDR)
|
|
}
|
|
if wp.LastCodecVideo == nil || *wp.LastCodecVideo != "hevc" {
|
|
t.Errorf("LastCodecVideo = %v, want %q", wp.LastCodecVideo, "hevc")
|
|
}
|
|
|
|
// Overwrite hints.
|
|
hints2 := userstore.VersionHints{
|
|
FileID: 99,
|
|
Resolution: "1080p",
|
|
HDR: false,
|
|
CodecVideo: "h264",
|
|
}
|
|
if err := store.UpdateProgressHints(ctx, "p1", "movie-1", hints2); err != nil {
|
|
t.Fatalf("UpdateProgressHints(overwrite): %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("LastFileID after overwrite = %v, want 99", wp.LastFileID)
|
|
}
|
|
if wp.LastResolution == nil || *wp.LastResolution != "1080p" {
|
|
t.Errorf("LastResolution after overwrite = %v, want %q", wp.LastResolution, "1080p")
|
|
}
|
|
|
|
// Hints for nonexistent progress row should be a no-op (not an error).
|
|
if err := store.UpdateProgressHints(ctx, "p1", "nonexistent", hints); err != nil {
|
|
t.Fatalf("UpdateProgressHints(nonexistent): %v", err)
|
|
}
|
|
|
|
// Verify hints survive in ListProgress.
|
|
all, err := store.ListProgress(ctx, "p1", "all", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListProgress: %v", err)
|
|
}
|
|
found := false
|
|
for _, wp := range all {
|
|
if wp.MediaItemID == "movie-1" {
|
|
found = true
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("ListProgress LastFileID = %v, want 99", wp.LastFileID)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("movie-1 not found in ListProgress results")
|
|
}
|
|
|
|
// Verify hints survive UpdateProgress (position-only update).
|
|
if err := store.UpdateProgress(ctx, "p1", "movie-1", 600, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress: %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("LastFileID after UpdateProgress = %v, want 99 (preserved)", wp.LastFileID)
|
|
}
|
|
|
|
// Verify hints survive SetProgress (unconditional position update).
|
|
if err := store.SetProgress(ctx, "p1", "movie-1", 400, 7200, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
wp, _ = store.GetProgress(ctx, "p1", "movie-1")
|
|
if wp.LastFileID == nil || *wp.LastFileID != 99 {
|
|
t.Errorf("LastFileID after SetProgress = %v, want 99 (preserved)", wp.LastFileID)
|
|
}
|
|
|
|
// Verify pre-migration rows return nil hints.
|
|
if err := store.SetProgress(ctx, "p1", "movie-no-hints", 400, 3600, noThreshold); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
wp, err = store.GetProgress(ctx, "p1", "movie-no-hints")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(movie-no-hints): %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatal("GetProgress(movie-no-hints) returned nil")
|
|
}
|
|
if wp.LastFileID != nil {
|
|
t.Errorf("Expected nil LastFileID for row without hints, got %v", wp.LastFileID)
|
|
}
|
|
if wp.LastHDR != nil {
|
|
t.Errorf("Expected nil LastHDR for row without hints, got %v", wp.LastHDR)
|
|
}
|
|
}
|
|
|
|
func testSectionOverrides(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-1", "home", "", []userstore.SectionOverride{
|
|
{
|
|
SectionID: "section-1",
|
|
Removed: true,
|
|
Title: "Recently Added",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides: %v", err)
|
|
}
|
|
|
|
got, err := store.ListSectionOverrides(ctx, "profile-1", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides returned %d overrides, want 1", len(got))
|
|
}
|
|
if !got[0].Removed {
|
|
t.Fatalf("Removed = %v, want true", got[0].Removed)
|
|
}
|
|
if got[0].SectionID != "section-1" {
|
|
t.Fatalf("SectionID = %q, want %q", got[0].SectionID, "section-1")
|
|
}
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-2", "home", "", []userstore.SectionOverride{
|
|
{
|
|
SectionID: "section-2",
|
|
Hidden: true,
|
|
Title: "Continue Watching",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides(profile-2): %v", err)
|
|
}
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-1", "home", "", []userstore.SectionOverride{
|
|
{
|
|
SectionID: "section-3",
|
|
Removed: true,
|
|
Title: "Top Picks",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides(profile-1 replacement): %v", err)
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-1", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after replacement): %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after replacement) returned %d overrides, want 1", len(got))
|
|
}
|
|
if got[0].SectionID != "section-3" {
|
|
t.Fatalf("profile-1 SectionID after replacement = %q, want %q", got[0].SectionID, "section-3")
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-2", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-2): %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides(profile-2) returned %d overrides, want 1", len(got))
|
|
}
|
|
if got[0].SectionID != "section-2" {
|
|
t.Fatalf("profile-2 SectionID = %q, want %q", got[0].SectionID, "section-2")
|
|
}
|
|
|
|
if err := store.ResetSectionOverrides(ctx, "profile-1", "home", ""); err != nil {
|
|
t.Fatalf("ResetSectionOverrides(profile-1): %v", err)
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-1", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after reset): %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("ListSectionOverrides(profile-1 after reset) returned %d overrides, want 0", len(got))
|
|
}
|
|
|
|
got, err = store.ListSectionOverrides(ctx, "profile-2", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides(profile-2 after profile-1 reset): %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides(profile-2 after profile-1 reset) returned %d overrides, want 1", len(got))
|
|
}
|
|
if got[0].SectionID != "section-2" {
|
|
t.Fatalf("profile-2 SectionID after profile-1 reset = %q, want %q", got[0].SectionID, "section-2")
|
|
}
|
|
}
|
|
|
|
func testSectionOverridesUserAddedFields(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
|
ctx := context.Background()
|
|
store := newStore(t)
|
|
|
|
input := userstore.SectionOverride{
|
|
SectionID: "",
|
|
IsUserAdded: true,
|
|
UserSectionType: "hidden_gems",
|
|
UserConfig: `{"min_rating":7.5}`,
|
|
UserTitle: "Hidden Gems",
|
|
}
|
|
|
|
if err := store.SaveSectionOverrides(ctx, "profile-ua", "home", "", []userstore.SectionOverride{input}); err != nil {
|
|
t.Fatalf("SaveSectionOverrides: %v", err)
|
|
}
|
|
|
|
got, err := store.ListSectionOverrides(ctx, "profile-ua", "home", "")
|
|
if err != nil {
|
|
t.Fatalf("ListSectionOverrides: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("ListSectionOverrides returned %d overrides, want 1", len(got))
|
|
}
|
|
|
|
o := got[0]
|
|
if !o.IsUserAdded {
|
|
t.Error("IsUserAdded = false, want true")
|
|
}
|
|
if o.UserSectionType != "hidden_gems" {
|
|
t.Errorf("UserSectionType = %q, want %q", o.UserSectionType, "hidden_gems")
|
|
}
|
|
if o.UserConfig != `{"min_rating":7.5}` {
|
|
t.Errorf("UserConfig = %q, want %q", o.UserConfig, `{"min_rating":7.5}`)
|
|
}
|
|
if o.UserTitle != "Hidden Gems" {
|
|
t.Errorf("UserTitle = %q, want %q", o.UserTitle, "Hidden Gems")
|
|
}
|
|
}
|