Pacearr has TypeScript checks, focused server/database tests, production build verification, and Docker smoke testing. Server tests run against a temporary SQLite database with no external services involved.
| Command | What it does |
|---|---|
npm run test:server |
Builds the server, compiles server tests, and runs Node's test runner |
npm test |
Delegates to npm run test:server: builds the server, compiles server tests, and runs Node's test runner |
npm run check |
Runs TypeScript checks for the client, shared types, server, and Playwright config/tests |
npm run build |
Builds the Vite client and TypeScript server |
npm run test:e2e |
Runs Playwright auth setup and the live-instance browser suite |
npm run test:e2e:auth |
Runs only the Playwright session-cookie setup |
Server tests use Node's built-in test runner against a temporary SQLite database. Safe to run any time — they touch no real data or external services. The Plex artwork redirect test uses temporary HTTP servers on loopback.
Playwright tests run against a live, fully configured Pacearr instance. They do
not mock the API or use the server test database. Auth uses a real
pacearr_session cookie because the devcontainer has no display for driving
Plex OAuth.
- Install Chromium with
npx playwright install chromium. - Copy
.env.playwright.exampleto.env.playwright. - Set
BASE_URLto the running Pacearr instance and paste the value of thepacearr_sessioncookie from the browser intoSESSION_COOKIE. A plainhttp://LAN address works too — you'll get a console warning about the session cookie travelling in plaintext instead of a hard failure. Onlyhttps://or a loopback host (localhost/127.0.0.1/::1) stay silent. - Run
npm run test:e2e.
The auth setup validates the cookie and saves it to
tests/playwright/.auth/storageState.json. Later runs reuse that session until
it expires. Delete the saved state and provide a fresh cookie to re-authenticate:
rm tests/playwright/.auth/storageState.json.env.playwright, generated auth state, test results, and the HTML report are
all gitignored and must stay local because they contain the active session
cookie or authenticated page data. The smoke
tests also fail on unexpected browser console errors and page errors, using the
selective client logging convention from #42.
The app's rate limits live in src/server/rate-limit.ts, which is shared
unchanged across the Migz93 self-hosted apps. There are two limiters: 3,000
requests per minute per client IP across the app, and 10 failed sign-in
attempts per 15 minutes. Built assets (/assets/), cached images (/images/)
and /favicon.ico don't count toward the global limit, so a full Playwright
run stays well under it.
If several unrelated pages suddenly fail together with "Too many requests", something is making far more requests than expected. Look for a polling loop or a new route that bypasses the exemptions rather than rerunning the suite.
Runs against a temporary SQLite database. Safe to run any time.
| Test | What it checks |
|---|---|
| Rolling show enrollment is idempotent | A Sonarr series can only create one Pacearr rolling-show row |
| Watch event import is idempotent | Duplicate source events are ignored and do not inflate watch history |
| Watch events with the same source event ID from two connections are both stored | Plex history keys and Tautulli reference IDs are only unique per server, so another server's event with the same ID is stored rather than dropped, and a user or series repair only touches the row from its own connection |
| A watch event stored under its server's URL moves to the stable ID only when it is the same watch | A row stored under the URL fallback moves to the install ID when the server reports the same ID, time, season, episode, show and viewer; a different install reusing the ID for another watch, or for the same second and episode of another show or viewer, is stored separately and the old row stays under the URL; other sources at the same URL are untouched |
| Expanded seasons are monotonic and not duplicated | Expansion state is sorted and duplicate-safe |
| Expanded seasons can be removed during progressive cleanup | Progressive cleanup can remove a season from expansion state |
| Prefetched episodes persist and clear with their lifecycle | Prefetch records retain their triggering user, reject duplicates, clear explicitly, and clear when the season expands |
| Pruning history events by retention only removes events older than the cutoff | history_events past historyRetentionDays is deleted; recent events, watch_events, and reclaimed_storage_events are untouched |
| Pruning history events does not crash on an extreme retention value | An overflow-inducing input (e.g. 1e308) is clamped to MAX_SAFE_RETENTION_DAYS instead of producing an invalid Date that throws |
| Pruning history events with a zero or negative retention value does not wipe every row | A retention value below 1 is floored, so the cutoff can't land on now-or-future and delete the entire audit log |
| Pruning history events does not crash or wipe every row on a NaN retention value | Number.isFinite is checked before the clamp, since Math.min/Math.max both propagate NaN rather than bounding it |
| Dry-run defaults are safe | New and legacy/partial settings resolve to dry-run enabled |
| The split library refresh job inherits old combined-job state | An upgrade carries recommendation-refresh run status into sonarr-library-refresh, avoiding a misleading “never run” status while the existing cache is warm |
| Migration 23 stamps history cursors with the connection configured at upgrade | A Plex cursor saved before connections were recorded gains the configured machine identifier; a cursor for a source no longer configured stays unstamped, so its next import is a full read |
| A history category filter matches an action and its dry-run twin | ?category= filters on the fixed action set from src/shared/history.ts, includes dry_run. variants, excludes other categories, and stacks with the level filter |
| Per-user activity windows the show count but not the last-watched timestamp | The Users page's "N shows active" respects the viewer activity window while "last watched" does not, so a quiet viewer shows when they were last seen rather than "never" |
| A disabled user's recent watch does not count as an active show, but still counts as last watched | Only an enabled viewer's progress keeps a season expanded, so the active-show count excludes disabled users while the last-watched timestamp stays informational |
| A recent special (season 0) does not count as an active show, but still counts as last watched | Specials never expand a season (processWatchEvent gates expansion on seasonNumber > 0), so counting one as "active" here would tell a viewer their switch is keeping a show expanded when it isn't |
A legacy watch_events.reconciled row still appears under the Sync category |
That action stopped being written, but an install that ran an older version can already have rows with it, and dropping it from the category map would make them disappear from the Sync filter |
updateUser with an empty patch preserves the current enabled state |
Locks in the patch.enabled ?? current.enabled fallback this layer relies on, independent of whatever the route above it does with an absent field |
| A recommendation cache missing any field, not just viewers/viewerCount, is treated as absent | The shape guard used to check only the two fields the last rename broke; a row missing a different field (or a malformed nested viewer) would have passed and then crashed the client downstream |
| A recommendation cache row with corrupted JSON is treated as absent, not as zero candidates | getRecommendationCache used to parse through a helper whose fallback-on-error returned [], which passes the shape check vacuously and reads as a genuine "0 candidates" cache instead of triggering the refresh callers expect from null |
| A malformed Sonarr library cache is treated as absent | Invalid JSON or a partial cached series must trigger the normal Sonarr fallback instead of looking like a valid empty library |
| A recommendation cache with non-numeric season entries is treated as absent | The shape guard checked retainedSeasons/droppedSeasons were arrays but never that their elements were numbers, unlike every other field in the same guard |
| Tautulli resolver prefers an exact username match and refuses to guess between ambiguous display names | username has no uniqueness constraint and display_name even less so — a single OR query with .get() would silently attribute one person's Tautulli history to a different Pacearr user on a tie |
| Tautulli resolver refuses to guess between two users whose usernames collide case-insensitively, and does not fall through to display_name on that tie | The username step trusted a bare .get() as if a match always meant one row; it did not, since username has no uniqueness constraint either and the lookup is case-insensitive. A third user with a matching display_name proves the null result is the designed refusal, not a coincidence — without it, the test couldn't tell "refused to guess" from "found nothing either way" |
| Tautulli resolver falls back to an unambiguous display name match | The case the fallback exists for — a Tautulli friendly name that matches nobody's username but exactly one display name |
| Tautulli resolver matches on the Plex username even when the Tautulli friendly name matches nobody | Regression for #75 — a Tautulli admin can rename the friendly name freely without touching Plex, so a friendly name matching nothing must not block a match on the real Plex username |
| Tautulli resolver falls back to the friendly name when the Plex username matches nobody | Plex Home/managed users have no real Plex.tv username, so Tautulli's username field for them is often empty or unrelated — the friendly name has to be tried independently, not just as a fallback field for the same string |
| Tautulli resolver refuses to guess when the Plex username is ambiguous, even if the friendly name uniquely matches a different user | An ambiguous username used to be indistinguishable from "no match" and fell through to the friendly name regardless — a coincidental unique friendly-name match could then attribute the event to a third user unrelated to the ones the username was ambiguous between |
| Tautulli resolver tries the friendly name when the Plex username only collides ambiguously on display_name, not on username itself | Blocking the friendly-name fallback was only meant for a genuine conflict on the strong signal (the real username field) — an ambiguous display_name match during that same lookup's own fallback step is just a failed primary lookup, and the independent friendly name can still resolve it |
| A previously orphaned Tautulli watch event can be repaired once its user resolves | Regression for #75 — INSERT OR IGNORE means a duplicate event is silently skipped forever unless something explicitly repairs its user_id; also locks in that repairing an already-assigned event is a no-op, not a re-attribution |
| Mapping a Tautulli identity persists it and links that identity's orphaned history | The Users-page mapping stores Tautulli's stable user ID, makes it the preferred future match despite a renamed username, and assigns previously unmatched events so their progress is available again |
| Mapping cannot replace a user's existing Tautulli identity | A second different stable ID, or an ID already mapped to another user, is rejected with the corresponding domain conflict error without overwriting either mapping |
| Unmapped Tautulli users display the names from their newest event | The mapping UI does not pair the latest activity timestamp with lexicographically selected stale names after a Tautulli rename |
| The cached Tautulli resolver preserves stable-ID and ambiguity safeguards | Full-history reconciliation uses its in-memory resolver without changing the stable-ID priority or the refusal to guess on ambiguous names |
| An ambiguous saved Tautulli username is never resolved through a weaker fallback | Two editable mappings that collide case-insensitively leave the event unmatched even when its friendly name could otherwise resolve to another user |
| Tautulli username backfill uses a managed user's friendly name when their username is blank | A database upgraded from before the editable field gets a usable Tautulli friendly name for a matched managed user whose event username is blank |
| Migration 17 repairs duplicate Tautulli IDs before adding the unique index | A pre-release duplicate retains the earliest user deterministically while later duplicate mappings are cleared |
| Migration 22 separates Tautulli active-session events without losing existing watch events | Rebuilds the source constraint so live activity cannot advance the completed-history cursor, while preserving existing history |
| Migration 27 stamps existing watch events with the connection configured at upgrade | Plex and Tautulli rows get the configured URL, never the stable ID, since the configured server may have replaced the one a row came from (each moves to the ID when its server reports the same watch again); the Plex cursor is restamped with the URL so the next import is a full read; an event under the same connection stays a duplicate while another server's event with the same ID is accepted |
| Migration 27 treats malformed connection settings as unconfigured instead of failing | A hand-edited setting holding a non-string URL or machine identifier stamps '' (or the URL fallback) rather than throwing on parameter binding and blocking startup |
| Migration 25 clears ignore records for shows that are already enrolled | A show can no longer be both enrolled and ignored; an upgrade removes overlaps earlier versions allowed and leaves other ignore records alone |
| Test | What it checks |
|---|---|
| A session check that moved nobody's progress records no history event | session-check can run once a minute; an unconditional entry buried History under "processed 0, changed 0" rows. The run is still logged |
| A session check that only advances a viewer's progress, without expanding or prefetching a season, still records a history event | processWatchEvent can persist a rolling_show_users update while returning changed: false (no premiere, or earlyPrefetchEnabled is off) — the audit-log gate must also react to progressUpdated, not just changed, or a genuine progress move goes unlogged |
| A session check expands and searches an unexpanded season when playback begins after episode 1 | A missed Plex notification for a season premiere must not leave the remaining episodes unmonitored or unsearched when the next observed playback event is later in that season |
| A dry-run session check expands an unexpanded season only once | Multiple viewers observed in the same unexpanded season must not create duplicate dry-run expansion history entries when dry-run intentionally leaves persisted expansion state unchanged |
| A rolling reconcile with nothing to change and no errors records no history event | Same rule for the six-hourly sweep |
| A rolling reconcile that only flips series-level Sonarr monitoring, with no episode/season change, still records a history event | changedSomething used to check only episode/file/search counts, missing plan.seriesMonitoringUpdate and season-level monitoring toggles — a scheduled sweep that only mutated series-level monitoring skipped the sonarr.baseline entry despite genuinely changing something |
| Test | What it checks |
|---|---|
| A Plex session key reused after a restart for the same episode numbers still stores the new playback | Regression for #169 — INSERT OR IGNORE silently dropped a new playback whose reused sessionKey and season/episode matched an old row. The new playback must be stored and advance progress, while repeated polls of it stay one event. A final playback that differs only by Session.id must also be stored |
| Without a Plex Session.id, a reused session key is still separated by viewer | The fallback ID for sessions missing Session.id must still separate two viewers who get the same key for the same episode |
| Test | What it checks |
|---|---|
| A local SSE playback notification triggers a session check | The monitor authenticates its local stream with the Plex token, records a live connection, accepts both Plex's named playing event with its root PlaySessionStateNotification payload and the legacy generic/nested notification shape, and safely ignores null notification frames without requiring a Plex server or an active viewer |
| A failed local SSE connection reports polling fallback | A Plex connection failure does not trigger a session check and leaves the scheduled fallback visible |
| An idle live SSE connection falls back to polling | A reverse proxy or Plex stream that stays open but stops producing heartbeats is reconnected rather than suppressing polling indefinitely |
| A monitor that begins before Plex is configured reconnects later | The live monitor does not become permanently unavailable when configuration appears after startup |
| A session-check trigger is coalesced while that job is running | Live notifications, schedules, and manual actions cannot cause overlapping session checks |
| A dependent manual job queues one follow-up after an active run | A refreshed Sonarr library cannot leave recommendations stale when an older calculation is already in flight; repeated triggers still coalesce to one follow-up |
| A job identifies a manual trigger | The session fallback can skip only scheduled polls while retaining Settings and SSE-triggered checks |
| Disabled jobs cannot be manually run or queued | Settings and internal callers cannot override a job the administrator disabled |
| Disabling a job cancels a queued manual follow-up | A job disabled while its active run has a coalesced manual follow-up cannot start that follow-up after the active run finishes |
| A scheduled collision retains the following timer | Skipping an in-progress recurring run does not silently stop that job permanently |
| Test | What it checks |
|---|---|
| A job registered partway through its interval runs one interval after its last run | A restart does not restart a long job's countdown from boot |
| An overdue job runs shortly after registration | A job whose interval elapsed while Pacearr was down runs after the catch-up grace, then waits a full interval |
| Overdue jobs are staggered | Catch-up runs at boot are spaced apart rather than all starting together |
| Updating a job without changing its interval or enabled state keeps its next run | Saving settings cannot postpone a job indefinitely |
| Changing a job's interval measures the new interval from its last run | Switching an interval away and back leaves the original due time |
| A manual run satisfies a pending catch-up | A startup runNow is not repeated by the catch-up moments later |
| A manual run moves a job's next scheduled run a full interval after it | A scheduled run cannot follow a manual run by less than one interval |
| An event-driven run can leave the recurring schedule untouched | A Plex SSE playback event cannot postpone the session polling fallback if the live connection drops right after it |
| Repeated interval edits keep an overdue job's catch-up slot | Editing an overdue job's interval back and forth cannot push its catch-up further out |
| Interval edits that make an overdue job temporarily not due keep its catch-up slot | Toggling an overdue job's interval to a not-yet-due value and back cannot push its catch-up further out |
| Disabling an overdue job releases its catch-up slot for the next overdue job | Toggling an overdue job off and on repeatedly, then disabling it, leaves the next overdue job the first slot rather than one behind every released reservation |
| A manual run releases the job's catch-up slot for the next overdue job | A manual run satisfies the catch-up, so a job that becomes overdue afterwards takes the first slot |
| A released catch-up slot ahead of another reservation is reused | With b still reserved, freeing the two slots before it lets a job that becomes overdue afterwards take the first freed slot, still spaced from b, rather than queueing after b |
| A run skipped while setup is incomplete is not recorded, and the job catches up once setup completes | A skipped catch-up persists nothing and leaves no next run; resumeAfterSetup() does nothing until ready, then the job runs once and its next run is a full interval later |
| A run requested before setup completes runs once setup does, even when not otherwise due | A queued run skipped for setup is still owed: after setup it runs as a catch-up despite a recent last run |
| A job whose last run failed before a restart catches up | A persisted error status retries after boot even when the last success is within the interval |
| A failed catch-up run waits a full interval before retrying | A persistently failing job cannot retry in a tight loop even though lastRunAt only advances on success |
| runAfterActiveAndWait waits for a fresh run after an active one, not the active run | A caller that needs work started after its request (the Sonarr tag import's full history read) is not satisfied by a run already in progress: it resolves only after a queued fresh run finishes |
| Test | What it checks |
|---|---|
| Oversized finite intervals are clamped | Values that are finite as minutes, hours, or days but invalid as milliseconds are bounded before persistence or scheduler conversion |
| Invalid interval forms stay safe | App-settings fall back safely for non-finite minute, hour, and day values; direct Jobs edits reject invalid or non-aligned minute values rather than silently rounding them |
| Test | What it checks |
|---|---|
| A loop that finishes within the interval logs nothing | Short runs of a long job stay quiet |
| Progress is reported once per interval with the running count and total | A long loop logs processed/total (plus its own fields) at each interval, never more often |
| The next interval is measured from the last line | A slow item that crosses the interval late does not let the following item log early |
| Several items per tick, with an unknown total | A paged read counts a whole page per tick, and a loop with no known length logs processed without a total |
| Test | What it checks |
|---|---|
getRecentLogs only reflects the in-memory ring |
Retained rotated files never leak into the ring-only read path |
currentLogFilePath points at the machine-readable transport's fixed symlink name |
Settings → Logs reads the same file the machine-readable rotating transport always symlinks to |
close() is idempotent |
Concurrent and subsequent shutdown calls share one completion path without ending winston twice |
mergeLogEntries drops exact duplicates but keeps same-millisecond entries that differ only in meta |
The Logs merge key can't collapse distinct entries that share a timestamp and message (e.g. reconcileRollingShows's per-show skip log) |
mergeLogEntries sorts the combined result chronologically |
Combining out-of-order sources still yields a chronological result |
| Entries below the configured level don't push kept entries out of the in-memory logs | The ring applies Winston's LOG_LEVEL filter, so a burst of suppressed debug/info entries can't evict the warnings and errors Settings → Logs falls back on |
readRecentLogEntries combines today's log file with the in-memory ring |
The Logs route sees history from both a prior restart (file) and this process's own activity (ring) |
| Logged metadata survives the full write/read round trip through the persisted file | Winston's second log argument is wrapped so metadata is nested under meta in the serialized file, not spread onto top-level fields where readTodaysLogEntries couldn't see it |
| The ring entry's timestamp matches the persisted file's timestamp for the same log call | write() supplies its own timestamp to winston instead of letting format.timestamp() generate an independent one, so mergeLogEntries' dedup key can't split one log call into two visible entries |
| Logging circular, BigInt, or serialization-throwing metadata does not throw, in the ring, the persisted file, or the Logs API's own merge | write() sanitizes metadata once before it enters the ring, so every downstream consumer (the persisted file, and mergeLogEntries via the Logs API) only ever sees an already-safe value |
| Logging the same object referenced twice preserves both, rather than marking the repeat as circular | sanitizeMeta tracks ancestry, not "every object ever seen", so two sibling properties referencing the same object aren't mistaken for a cycle |
| Logging a root metadata value with no JSON representation (a function or Symbol) does not throw and omits metadata | JSON.stringify returns undefined for these at the root, so sanitizeMeta must recognize that instead of handing undefined to JSON.parse, which throws |
The human-readable log retains falsy scalar metadata (0, false, "", null) instead of treating it as absent |
humanFormat checks whether meta is present, not whether it's truthy, so a real but falsy scalar isn't silently dropped from pacearr.log |
| Test | What it checks |
|---|---|
| A deleted cached image is fetched again | The in-memory file index is invalidated when the on-disk image disappeared, allowing a replacement fetch instead of returning a broken URL |
| Test | What it checks |
|---|---|
| New-show triage creates a boundary only on enable | The authenticated HTTP route sets a new activation boundary on disabled → enabled, while enabled → enabled saves preserve it |
| Saving a newly usable or changed Tautulli connection queues a history import | Through the HTTP route: a disabled save imports nothing, enabling imports once, an identical save does not import again, and a new server URL imports again |
| Turning on the season search clears recorded seasons only when it becomes active | Through the HTTP route: the toggle alone does nothing while triage is off; turning both on clears every record and increments the activation number; saving again while on changes neither; turning either setting off and on again clears again with a new number; a non-boolean is rejected |
| Saving Plex resumes waiting jobs after user discovery and queues a history import only when discovery succeeds | Through the HTTP route with discovery stubbed: a new connection runs discover → resume → import; an identical save does not import; a failed discovery still resumes waiting jobs but does not import |
| Test | What it checks |
|---|---|
| Dry-run blocks Sonarr mutations | Monitoring updates, searches, file deletions, tag creation and series-tag edits send no HTTP requests while dry-run mode is enabled |
Runs against a temporary SQLite database and an in-memory fake Sonarr that applies series-editor tag changes.
| Test | What it checks |
|---|---|
| A library refresh skips tag reconciliation after its Sonarr connection changes | Changing either the URL or API key while the series list is being read sends no tag requests and preserves queued removals; a fresh refresh catches up on the saved connection. Saving identical credentials still reconciles. |
| Tag writes add and remove only Pacearr's own tags, through the series editor | Ignore, enrol (ignored → enrolled), unenrol and restore each add or remove the matching Pacearr tag, every editor request uses add/remove with only Pacearr's tag IDs, and an unrelated tag on the series survives all of it |
| A reconcile adds missing tags but never removes a tag Pacearr has no record for | With an empty database facing tagged series (the state after database loss), a library refresh sends no removal; an enrolled show missing its tag gets it |
| A connection change during tag reconciliation stops further writes and queue clearing | A change during the busy-show wait, tag lookup/creation, removal lookup/write, or addition write stops further tag writes. Unsettled removals remain queued, including a removal whose in-flight write finishes after the change; a fresh refresh catches up after the busy-show case. |
| A tag removal that fails is queued and retried until it succeeds | A removal Sonarr rejects stays in sonarr_tag_removals across refreshes and is sent and cleared once Sonarr accepts it |
| Dry run sends no tag request, and queued removals catch up once it is off | No /tag or /series/editor request in dry run; afterwards queued removals and missing adds are applied, and a removal for a show ignored again before it was sent is dropped rather than stripping the current tag |
| Tag writing is off by default: nothing is sent, removals queue, and import still works | sonarrTagsEnabled defaults to false; while off, no /tag or /series/editor request is sent but removals are queued, the import preview still works, and turning writing on applies the queued removal and missing adds |
| A series confirmed deleted from Sonarr drops its queued tag removals without sending them | With tag writing off, a queued removal for a restored show (no record left) and one for an enrolled show are both cleared once Sonarr confirms each series is gone, and no tag request is sent |
| A reconcile defers a show another operation holds, logs it, and tags it once it is free | A busy show is not written during the main pass; its title is logged; one that becomes free within the retry window is tagged in the same run, and one still busy is reported as deferred for the next refresh |
| A deferred show whose re-read fails stays deferred instead of being counted as done | A transient Sonarr error re-reading a freed show is logged and keeps it in the deferred count, rather than being treated as a deleted series and silently dropped |
| A re-adopted show is not reconciled or cleaned up until a full history read completes | With a zero-day cleanup delay, a rolling reconcile before history is rebuilt sends nothing for an imported show; a successful full history reconciliation clears the guard and the next reconcile treats it normally |
| Importing from Sonarr tags is additive, skips conflicts, and re-adopts without a pilot baseline | The preview splits tagged shows into enrol/ignore/already known/conflict and treats a queued removal as absent; the import only creates records the confirmation listed, never changes an existing one, records still-monitored seasons as expanded, and sends nothing to Sonarr |
| Test | What it checks |
|---|---|
getHistory maps valid Tautulli history without collapsing username and user |
Regression for #75 — these fields stay distinct, and a malformed neighboring row cannot discard valid history from the same response |
getActiveSessions parses real get_activity rows, which carry no start time, and keys them by playback session |
Regression for #168 — real get_activity rows have no started/date, and the old parser skipped every one, so the job never recorded an event. The fixture matches a live payload. Rows are keyed by session_id (or by a session key scoped to viewer, episode and day), dated when observed, and malformed rows are still rejected |
| Test | What it checks |
|---|---|
| Redirected Plex thumbnails are backed up, overlaid and restored without contacting another origin | A local fake Plex returns a real 302 for an episode thumbnail; the transcoder supplies a full-HD image, the original is backed up, the overlay is rendered and uploaded, and restoration returns the original. The same flow also runs through a fake subpath proxy that strips its prefix before Plex resolves the internal source. The transcoder requires authentication on both its outer request and local source path; the redirect target receives no request. Direct bytes still recognize an existing overlay after its thumbnail URL changes. |
| Poster downloads preserve direct bytes, reject invalid responses and report failed transcoder causes | Direct images retain their bytes; HTTP, network and non-image failures do not trigger the redirect fallback. Transcoder redirects are rejected with the nested cause retained, and every request stays on the configured origin with redirect protection and a timeout. Source authentication preserves query parameters while the proxy base path applies only to the outer request; remote thumbnail URLs are rejected before any request. |
| Integration error formatting includes nested and non-Error causes and stops at cycles | Nested causes remain readable and circular causes cannot hang logging. Artwork warnings include the cause chain. |
| Test | What it checks |
|---|---|
findShowForArtwork requests a single seed item per TV section |
Regression for #200 — the seed request to /library/sections/{key}/all, which fetches one show so the lookup can call that show's /matches endpoint, sends both X-Plex-Container-Start=0 and X-Plex-Container-Size=1 as URL query parameters. Plex ignores the size without the start, so dropping it downloads the whole section per identifier and times out under load |
findShowForArtwork picks the copy in Sonarr's folder when one show is in several libraries |
Regression for #201 — when the ID match finds one Plex GUID in two TV sections, the copy whose Location folder equals Sonarr's series.path (ignoring a trailing / or \) gets the artwork, not the lookup being skipped as ambiguous |
findShowForArtwork skips a show in several libraries when none is in Sonarr's folder |
The folder tie-break needs an exact match; with no copy in Sonarr's folder (for example differing mount points) the lookup still returns null rather than guessing a library |
findShowForArtwork skips a show in several libraries when more than one is in Sonarr's folder |
The folder match must be unique; when two libraries point at the same folder, the lookup returns null instead of picking the first matching copy |
findShowForArtwork never lets Sonarr's folder pick between different shows |
Candidates with different Plex GUIDs stay ambiguous even when one folder matches Sonarr's path, so a bad upstream ID match can never change an unrelated show's poster (#111) |
findShowForArtwork uses a single match whether or not its folder is Sonarr's |
The folder is only a tie-break; a single ID match is used as before, including when no Sonarr path is supplied |
| Test | What it checks |
|---|---|
| An active Tautulli session retries expansion after a series-operation collision and is then deduplicated | A missed Plex live event is recovered after a competing session job releases the series lock; identity repairs reach normal rolling work without repeating a dry-run prefetch; completed-history cursors stay isolated and later duplicate polls remain silent |
| A reused Tautulli session key is a new playback event, while repeated polls of one playback are not | Regression for #169 — through checkTautulliActiveSessions(), a new session_id on a recycled session key stores a second row for the same episode, while repeated polls of each playback add nothing |
| Test | What it checks |
|---|---|
| Finale selection finds the next real season only from a season's last known episode | The last episode is the highest one Sonarr lists (aired or not), an episode past it never matches, the next season skips gaps and season 0, and nothing is selected when no later season exists |
| Starting a season's last episode expands the whole next season, with early prefetch off | A live session on S1E10 of 10 monitors the rest of season 2, runs SeasonSearch, marks it expanded and records a -finale history source; the setting does not depend on earlyPrefetchEnabled |
| A watch past the last episode Sonarr lists does not expand the next season | S1E11 against a Sonarr season ending at E10 means stale or mismatched numbering, so only an exact match to the last listed episode triggers |
| With the setting off, the finale leaves early prefetch behaving as before | The finale only prefetches E02–E03, and season 2 is not expanded |
| A finale expansion supersedes early prefetch of the same season | Earlier prefetch records for the next season are cleared by the expansion, and no second prefetch runs |
| A one-episode season expands both itself and the next season from its only episode | E01 as finale still reaches the finale check after expanding its own season |
| A dry-run finale records the expansion without changing Sonarr or expanded seasons | No Sonarr writes, expanded_seasons unchanged, one dry_run.sonarr.expand_season entry, and a repeat poll of the same playback stays silent |
| The rolling reconcile expands the next season for a finale watched before the setting was enabled | A stored finale event is never reprocessed, so the sweep catches up from active progress: season 2 expands (active-progress-reconcile-finale), its prefetch records clear, and a second sweep does not expand again |
| The rolling reconcile ignores a finale watched by a viewer outside the activity window | Catch-up only acts on active progress, so an old finale watch does not expand anything |
| The rolling reconcile prefetches for a viewer whose progress reached the trigger without a processed watch event | Prefetch is applied from stored progress by the sweep (active-progress-reconcile), not only from live events, and a second sweep adds nothing |
| Enrolment applies a stored finale watch the same way a live watch would | Enrolment's own history read skips the locked series, so enrolment applies viewer positions itself: the stored S1E10 expands S1 and S2 (enroll-finale) |
| Enrolling a viewer on a finale keeps the next season's files that finale expansion needs | With S2 already downloaded and the viewer's stored progress on S1E10, enrolment expands S2 and sends no file DELETE — positions are applied before the pass that deletes files |
| Enrolling a viewer inside the prefetch trigger keeps only the prefetched next-season files | With stored progress on S1E8 and S2 downloaded, E02–E03 are prefetched and kept; only E04–E05 files are deleted |
| A dry-run rolling reconcile records one expansion for two viewers in the same unexpanded season | Dry run never persists an expansion, so the sweep's own dedup set must stop the second viewer from recording it again; callers that pass no set get a fresh one per show |
| A first watch inside an unexpanded season expands it and still prefetches the next when near its end | Expanding the current season no longer suppresses prefetch from the same watch: S2E4 of 5 expands S2 and prefetches S3 E02–E03 |
| Scheduled reconciliation keeps a finale-expanded season while its viewer is still on the previous season | With a zero cleanup delay, the six-hourly sweep neither unmonitors, deletes nor un-expands the next season while the viewer's progress is still on the finale |
| Test | What it checks |
|---|---|
| Rolling plan never monitors, searches or deletes the files of excluded items | An excluded season is dropped from retention and its flag disabled; excluded episodes are target-unmonitored, never pilot- or season-searched, and their files are protected while ordinary non-pilot files are still deleted |
| Excluding an expanded season unmonitors all of it without searching or deleting files | The one-off Sonarr update unmonitors every episode, sends no search or DELETE, removes the season from expanded_seasons and records show.season_excluded |
| Watching an excluded season does not expand it or trigger any Sonarr work | Live watches of E01 and the finale send nothing to Sonarr, and the rolling reconcile's catch-up of that stored position never monitors, searches or expands the season |
| A finale does not expand, or prefetch into, an excluded next season | With finale expansion and early prefetch both on, a season 1 finale leaves the excluded season 2 untouched and stores no prefetch records |
| Expanding a season keeps its excluded episode unmonitored and unsearched | Sonarr re-monitors every episode when a season is monitored, so the expansion unmonitors the excluded episode again afterwards |
| Trimming an expanded season back to its pilot resets its episode exclusions | The scheduled reconcile trims an inactive expanded season in one pass: its excluded E01 is monitored again, the excluded non-pilot's file is deleted with the rest, resetExcludedEpisodes is recorded, and an exclusion in a never-expanded season is kept |
| Progressive cleanup from a watch also resets the trimmed season's episode exclusions | The same reset through cleanupSeasonToPilot, triggered by a viewer moving on to the next season |
| A dry-run trim keeps the season's episode exclusions | Dry run sends nothing to Sonarr and leaves expanded seasons and exclusions unchanged |
| Including a season restores only its pilot and searches it when missing | The season returns to the pilot-only baseline: EpisodeSearch for E01 only, no expansion |
| Including an episode restores the monitoring its season implies | Monitored and searched in an expanded season; left unmonitored, with nothing sent, in a pilot-only season |
| Including an episode monitors it when an active viewer holds its not-yet-expanded season | Include uses the rolling plan's target, so a season retained by an active viewer but not yet marked expanded gets the episode monitored and searched immediately |
| Including the episode an active viewer is on counts that viewer toward its season | The viewer's progress on the episode being included is judged without its exclusion, so their season is retained and the episode is monitored and searched |
| Dry run stores an exclusion without changing Sonarr or expanded seasons | No Sonarr writes, expanded_seasons unchanged, the exclusion is stored and dry_run.show.season_excluded is recorded |
tests/server/season-expansion-reasons.test.ts — Expansion reasons, manual expansion and requested seasons
| Test | What it checks |
|---|---|
| Requested seasons are only the monitored ones when some, but not all, real seasons are monitored | Every real season monitored, or none, selects nothing; specials are ignored |
| Migration 29 records existing expanded seasons as watched and skips malformed state | Seasons expanded before reasons existed keep showing as watched, with no user or time, and a malformed expanded_seasons value cannot block startup |
| Each way expanded-season state is cleared also clears its reason | Trim, reconcile, reset and unenrolment drop the reason; a season the reconcile keeps keeps its reason, and one it adds from viewer progress is watched |
| A season expanded by watching records the viewer, and excluding it clears the reason | The reason, user and time are stored and written to history; excluding the season removes them |
| The Expand button monitors and searches the season once and records it as manual | One SeasonSearch, every episode monitored, manual reason and history, and the show's last activity unchanged |
| The Expand button refuses an expanded, excluded, missing or busy season | Each is refused as ok: false with nothing sent to Sonarr |
| A dry-run Expand records the planned expansion without changing Sonarr or expanded seasons | Only dry_run.sonarr.expand_season is recorded |
| A manually expanded season nobody watches shows its trim date and is trimmed after the cleanup delay | The show page has no trim date until the reconcile starts the timer, then the timer plus Cleanup delay; after the delay the season is trimmed to E01 and its reason cleared |
| Triage enrolment keeps the requested season expanded, searches it once and records it as requested | The requested season is never unmonitored, gets one SeasonSearch and a requested reason; the other seasons get the pilot baseline |
| Triage enrolment keeps the requested season through the deferred history-repair pass | With history configured, the later auto-triage-history pass does not search it again or unmonitor it, and starts its inactivity timer |
| A triage enrolment that failed part-way keeps its requested season when the next poll resumes it | The resumed enrolment uses the stored classification, monitors the season and searches it once |
| A whole-show request whose baseline failed part-way is not reclassified as requested seasons on resume | The classification is stored with the pending enrolment before any Sonarr change, so seasons the failed baseline left monitored are not expanded on the retry |
| A requested season nobody watches is trimmed to its pilot after the cleanup delay, and a viewer keeps it | Normal retention applies to requested seasons |
| Triage enrolment of a series with every season monitored applies the plain pilot baseline | A whole-show request expands nothing |
| A manual enrolment keeps today's pilot baseline even when some seasons are monitored | Requested seasons are kept only for automatic triage enrolment |
| Test | What it checks |
|---|---|
| Existing series are ignored while new series at or below the limit are searched | The activation timestamp excludes the existing library, and the strict “more than” comparison sends an 80-episode series to SeriesSearch |
| Automatic enrollment history repair is coalesced | A batch of large new shows causes one full Plex history read after triage, rather than one whole-history read per enrolled series or duplicate pilot searches; it immediately applies pending active progress for existing enrolled shows |
| Automatic enrollment defers cleanup until history repair is clean | A degraded shared history read preserves episode files, while a clean read allows the correction pass to delete unretained files |
| Automatic enrollment uses Tautulli-only history repair | Enabled Tautulli without Plex still triggers the shared history read and defers file cleanup |
| A series above the limit is enrolled onto the pilot baseline | The large-series path reuses enrollment rather than issuing a full series search |
| Dry-run triage remains pending for live mode and a completed decision is not repeated | A dry-run never consumes an arrival; the first live run searches it and later polls do not repeat that command |
| Dry-run does not persist a fallback baseline | An undated dry-run response cannot suppress that series if Sonarr later supplies a post-activation added time in live mode |
| A changed activation stops an in-flight poll | A poll that started before triage was re-enabled cannot apply its old activation boundary |
A missing Sonarr added field uses a first-poll ID baseline |
The fallback never acts on pre-existing series, but detects a newly appearing ID on a later poll |
A pre-existing series remains ignored if its added field disappears |
An ID recorded from the initial timestamped response cannot become a fallback candidate later |
| A failing series does not block later arrivals | Per-series failures remain pending and visible in History while later new series continue through triage |
| A manual enrollment is not resumed by automatic triage | Only an explicitly pending automatic enrollment can receive the pilot baseline on a retry |
| A failed automatic enrollment cannot mark a later manual enrollment pending | A failure before Pacearr creates its rolling-show row leaves no marker for a later manual enrollment to inherit |
| A pending-marker write failure releases its enrollment lock | A database error before the mutation phase cannot permanently block later work on that series |
| Unenrolling a partial automatic enrollment clears its pending marker | A later manual enrollment cannot inherit stale automatic triage state after unenrollment |
| A full-series search waits for another series operation | Triage retries instead of overlapping a concurrent enrollment or rolling mutation |
| Retrying a partial large-show enrollment resumes it | A retry reuses the persisted rolling-show row without writing a second enrollment history entry |
| Dry run enabled during a poll leaves a new series pending | A full-series search blocked by a dry run switched on mid-poll is not recorded, so the first live poll searches it |
| A season monitored after a search triage is searched once | A later-requested season gets one SeasonSearch; repeat polls send nothing; a season unmonitored and monitored again is searched again |
| A search triage recorded before season tracking starts from its monitored seasons | A row with no recorded seasons is filled without searching, then a later season is searched |
| An enrolled search-triaged show is left to rolling monitoring | No season search while enrolled, and the seasons an unenrolment monitors are recorded without searching |
| A failed newly monitored season search is retried | The failed season stays unrecorded and is searched on the next poll |
| Enrolling and unenrolling between polls does not search every season | Unenrolment forgets the recorded seasons, so the next poll records the re-monitored seasons without searching |
| Dry run defers a newly monitored season search | No command in dry run; the season is searched on the first live poll |
| Dry run enabled during a poll leaves seasons unrecorded | Dry run switched on after the poll read its settings, or while a season search awaits Sonarr, sends no further search and records nothing as searched; a later live poll searches the rest |
| A season unmonitored during dry run is forgotten | The record drops an unmonitored season even when dry run skips the search, so monitoring it again is searched once live |
| An unenrolment during another show's season search is not undone | Recorded seasons are re-read under the series lock, and a series unenrolled after the poll's series list was read is skipped until the next poll, so the poll cannot record a stale state that would search every re-monitored season |
| A season monitored on a series that predates triage is searched once | A baseline series gets its monitored seasons recorded without searching, then one SeasonSearch per season monitored later |
| Newly monitored seasons are left alone while the setting is off | No search and no record while Search newly monitored seasons is off |
| Recording many existing series reads only the poll's series list | Filling empty records makes no per-series Sonarr reads |
| Turning the season check on during a new series' search leaves its record empty | A record written from the poll's older snapshot after the activation clear would survive it, so it is left for the first active poll, which records without searching |
| A series listed without its seasons is neither recorded nor pruned | Triage records nothing for a search with no season data, the next poll with seasons records them without searching, and a later list missing them keeps the record |
| Turning the setting off mid-poll stops season searches | A season search in flight when the setting is turned off is the last one sent |
| Triage turned off and on with the same boundary mid-poll still stops the season check | The season check's own activation number changes even when the triage timestamp repeats, so the in-flight poll sends no more searches and writes no record after the clear |
| A triage activation change stops season searches | Disabling triage while a season search awaits Sonarr stops later seasons and series in that poll, and leaves the series in flight unrecorded, since turning it back on clears every record |
| Test | What it checks |
|---|---|
| Session IDs are hashed before persistence and cannot be used as stored | A database read contains only the SHA-256 session-token hash, not a replayable bearer token |
| Existing plaintext session IDs are migrated without invalidating their cookie tokens | A version-13 database upgrades its session row to a hash while the original cookie token still resolves its owner |
| Session signature validation rejects malformed non-ASCII input safely | A visually 64-character but UTF-8-long signature returns false instead of reaching timingSafeEqual with mismatched byte lengths |
| Plex, Sonarr, and Tautulli reject unsafe URLs and configure credentialed request safeguards | Non-HTTP(S) and embedded-credential URLs are refused; successful connection checks plus Plex.tv account, discovery, friends, and token-ping requests pass redirect: "error" and an abort signal to fetch |
| Integration request paths cannot replace the configured origin | Absolute, non-HTTP(S), and protocol-relative paths cannot escape the administrator-configured Sonarr or Tautulli origin |
| Test | What it checks |
|---|---|
| Exempt paths | /assets/, /images/ and /favicon.ico (with or without a trailing slash) are exempt from the global limit, while API and page routes are not |
| Global limiter | The request after 3,000 in a minute gets a JSON 429 with draft-8 RateLimit headers, only the first rejection is logged, and exempt paths never use up the allowance |
| Sign-in limiter | Successful sign-ins don't count, the attempt after 10 failures gets a JSON 429, and one warning is logged |
| Test | What it checks |
|---|---|
| History import batches events outside the activity window while still applying rolling logic to recent ones | A mixed batch of one old and one recent watch event routes the old one through the batched insert-only path (no season expansion) and the recent one through the Sonarr-touching path (expands its season), with accurate imported/matched/unmatched counts across both |
| History import moves a Tautulli event to the later date of a resumed play and applies it as a new watch | Regression for #207 — an incremental read of a merged play under an already-stored reference ID moves its date forward, the viewer's progress follows, a resume inside the activity window expands its season, and neither an older re-read nor another Tautulli user's play reusing the ID moves it |
| A full history read moves a stored Tautulli event to a resumed play's date and refreshes progress | The batched insert-only path (full reads and plays outside the window) also moves the date and refreshes the viewer's rolling progress, without running rolling logic |
| A dry-run history import expands an unexpanded season only once | The watch-event expansion and active-progress reconciliation share virtual expansion state, so dry run records one expansion and one changed result for the same season |
| History import uses the cached Sonarr library | Prevents every history import from repeating the full Sonarr /series request when the library refresh job has already populated its cache |
| History import reads a changed or unknown Tautulli server in full instead of resuming from another server's cursor | A watch older than the stored cursor is imported when the cursor belongs to a different install or records no server; the same install still resumes incrementally; a cursor stamped with the configured URL is read in full once, since the install behind the URL may have changed |
| History import keeps a new Plex server's events that reuse an old server's history keys | A second server's event with the same historyKey is imported rather than dropped, and learning a server's machine identifier after it was identified by URL imports no duplicates |
| History import identifies Tautulli by its install ID, across URL changes and upgrades | Rows stored under the URL (as after upgrading) move to Tautulli's pms_uuid with no duplicates, a new URL for the same install imports nothing again, and a new install's events are kept even when they reuse reference IDs, including a reinstall behind the same URL and API key with no settings save |
| History import does not attribute a replaced Tautulli's history to the install now at its URL | A fresh install that replaced the old one behind the same URL before the upgrade keeps its watch that reuses an old reference ID, and the old install's row is not moved to the new install ID |
| Discovering Plex users links history imported before they were known and refreshes their progress | A friend's orphaned event (Plex.tv account ID) and the owner's (server-local ID 1) are linked on discovery, and both viewers' rolling progress reflects them |
| Discovering Plex users leaves history unlinked when its account ID belongs to more than one user | A friend whose Plex.tv ID is 1 (the owner's server-local ID) and two stored users sharing an account ID: neither event is linked, so no viewer's progress moves |
| Tautulli history resolves through its own rating-key metadata, not its title | A Tautulli grandparent_rating_key is resolved through Tautulli metadata and its TVDB/IMDb GUIDs, so a display-title mismatch cannot block a verified Sonarr association |
| History import continues with Tautulli when Plex is not configured | A Plex configuration error is reported and audited without preventing configured Tautulli history from importing |
| History import rejects a non-unique Sonarr external ID | A duplicate TVDB/IMDb value in Sonarr leaves the event unmatched rather than selecting whichever series appeared first |
| History import rechecks a stale missing source identity | A cached missing Plex/Tautulli identity is revalidated after one day, allowing later metadata repairs to link the event |
| History import stops repeated failed source identity lookups | Three consecutive Plex or Tautulli metadata failures stop further uncached lookups from that source for the current job, so a full reconciliation continues with unmatched events instead of pacing one failed request per event |
| History import scopes identity cache entries to the configured source connection | Reused Plex and Tautulli rating keys are resolved again after their configured connection changes, rather than reusing metadata from the prior instance |
| Sparse Plex history uses title search only for verified ID discovery | A single Plex title-search candidate links through matching TVDB metadata despite an alternate title; multiple candidates remain unmatched without a metadata request |
| Recommendation refresh is cache-only | The calculation job waits for a populated library cache rather than issuing another whole-library Sonarr request |
| A full history reconciliation repairs a previously orphaned Tautulli event and refreshes rolling progress | Regression for #75 end to end — a full reconcile re-fetches an event that's a duplicate by (source, source_event_id), so the fix has to repair it in place rather than rely on a fresh insert; also confirms rolling_show_users, not just the raw watch_events row, picks up the repaired progress |
| A full history reconciliation refreshes rolling progress after repairing an orphaned Plex series link | A previously unmatched Plex event can be linked by verified metadata during a full reconciliation; its viewer progress must be refreshed immediately rather than waiting for the rolling-reconcile schedule |
| Enrollment repairs and seeds previously unmatched history with a full verified source read | Enrolling after a source event was stored without a verified series link re-reads source history, repairs the exact event, and seeds viewer progress immediately; the read also applies pending active-progress expansion for other enrolled shows |
| Manual enrollment applies its pilot baseline before its full history reconciliation | Existing progress is applied to Sonarr immediately, while the slower full source read remains a corrective pass for missed activity; non-pilot files are not deleted until that read succeeds |
| Manual and automatic enrollment preserve files after a degraded history reconciliation | An integration read that returns errors (rather than throwing) must leave episode files intact while applying monitoring from whatever progress is available |
| Manual Tautulli mapping refreshes rolling progress from relinked history | Assigning an unmatched Tautulli identity immediately rebuilds its enrolled-show progress from stored history, without waiting for the next import |
| Reset clears prefetch targets before applying the pilot baseline | Reset removes persisted prefetch targets before reconciliation calculates which episodes to unmonitor and delete |
| Dry-run reset projects prefetch cleanup without mutating state | Dry-run reset excludes prefetched episodes from the projected monitoring and deletion plan while retaining their records |
| Dry-run expansion preserves prefetch targets | Reprocessing an already-expanded season in dry-run mode does not delete its persisted prefetch records |
| Scheduled reconciliation reclaims stale prefetches | A prefetch with no active viewer need beyond the cleanup delay is cleared, unmonitored, and its file is deleted |
| Scheduled reconciliation clears prefetches promoted to a retained season | When active viewer progress makes a prefetched season fully retained, the sweep expands it through the same expandSeason path a live watch uses (audited as active-progress-reconcile), which removes its per-episode prefetch records so the UI cannot display stale prefetch markers |
| Progressive cleanup toggle protects stale prefetches | Disabling progressive cleanup prevents stale-prefetch records and files from being reclaimed |
| Progressive cleanup deletes a multipart file shared only by eligible non-pilot seasons once | The complete cleanup batch is evaluated together, so one eligible season does not incorrectly protect a file needed only by another eligible season |
| Enrolled lists every enrolled show, including one the Sonarr library cache has not seen yet | Regression for #196 — membership comes from rolling_shows, so an enrolment between library refreshes is listed and searchable immediately with its placeholder Sonarr fields flagged as unknown, while cached shows keep their Sonarr enrichment and the Sonarr tab stays a library-cache view |
| Ignored lists every ignore record, whatever the recommendation cache holds | An ignored show below the savings threshold keeps its cached details, one absent from every cache is listed by its stored title with its size and savings flagged as unknown, and restoring removes it and updates the count |
| Enrolling a show clears its ignore, and an enrolled show cannot be ignored | Enrolment removes the ignore record, a later ignore is refused, and a stale cached recommendation for the enrolled show is not listed |
| A library refresh removes Pacearr records only for series Sonarr confirms deleted | A series missing from the list loses its enrolment or ignore only when its direct lookup returns 404; a stale list read (lookup 200) or a failing lookup (500) keeps the records, and each removal is recorded in history |
| A Sonarr connection change stops deleted-series cleanup | Changing the URL or API key during the series-list read skips publishing the stale library cache, and a fresh refresh publishes the new list. A change during the series-list read or deletion lookup preserves enrolments, ignores and queued removals; a change during Plex artwork restoration also keeps the enrolment and backup, releases the operation lock, and lets a fresh refresh retry cleanup. |
| An empty Sonarr series list or dry run never removes Pacearr records | An empty list (misconfigured or reset Sonarr) is not treated as every series deleted, and dry run keeps both the enrolment and the ignore record, recording a dry_run. history event for each instead |
| A deleted series keeps its enrolment and artwork backup until Plex artwork is restored or gone | A failed Plex poster restore keeps the enrolment and the only copy of the original poster; once Plex reports the item gone (404), the enrolment and backup are removed |
| Early prefetch selection skips the pilot and caps the candidate count | Episodes selected from the next season start after E01 and respect the configured count | | Rolling plans preserve prefetched episodes individually | Prefetched episode targets remain monitored without retaining the entire season | | File cleanup protects multipart releases needed by a retained episode | Sonarr files shared with a pilot or another retained episode are not deleted; a file shared only by disposable episodes is deleted once, including when the retained link is in another season |
Read-only. Safe to run against a live instance.
| Test | What it checks |
|---|---|
| Primary pages load | Dashboard, Shows, Users, History, and Settings render their headings without browser console or page errors |
| Sidebar navigation links are present | The five primary navigation links render inside the app navigation |
| Unauthenticated requests redirect to the login page | A fresh browser context is redirected from /dashboard to /login |
Read-only. Safe to run against a live instance: form tests discard their changes, or intercept their save requests in the browser so nothing reaches the server.
| Test | What it checks |
|---|---|
| All settings tabs are visible | General, Plex, Sonarr, Tautulli, Logs, Jobs, and About render |
| Clicking settings tabs updates the URL | Each tab writes its expected ?tab= query parameter |
| Opening a one-hour job schedule preserves its 60-minute preset | The Jobs editor reads its rendered “Every 1 hour” description as 60 minutes, so saving without a change cannot turn the schedule into every minute |
| Dependent settings stay visible but follow their parent toggle | Early season prefetch's options stay on the page whichever way the toggle is set, are enabled only while it is on, and Discard restores the toggle |
| The save bar only appears once something changes | The General tab's save bar is hidden until a field changes, and hides again once the field is back at its saved value |
| A save error can be dismissed once the form is back at its saved values | A client-side validation error shows in the save bar; once the field is restored, the bar offers Dismiss, which clears the error and the bar |
| A settings tab and tab switching are locked while its save is in flight | While a held save is pending, the tab's fields and the other tab buttons are disabled; once it fails they re-enable, and after reverting the field the error can be dismissed |
| A Plex save sends the artwork setting before the connection | The artwork PATCH, which is safe to repeat, is sent before the connection POST, so a failed PATCH cannot leave an already-saved connection to be posted again on retry |
| A Sonarr save sends the connection before the tag setting | A library refresh started by turning tag writing on cannot run ahead of a newly saved connection |
| A save whose settings reload fails says it was saved and blocks saving until refresh | When the save succeeds but reloading the settings fails, the save bar says the save went through, and a page banner asks for a refresh; discarding keeps the banner and saving stays disabled, so stale values cannot undo the save |
| Discarding after a partly failed save shows what the server now holds | When a Sonarr connection save lands but the tags save fails, the form re-syncs from the reloaded settings, so Discard leaves the saved base URL in place instead of the old one |
Read-only. Safe to run against a live instance.
| Test | What it checks |
|---|---|
| Shows tabs and filters render | Enrolled, Recommendations, Ignored, Sonarr, and the sort control render |
| Shows tab updates the URL | Selecting Recommendations writes ?tab=recommendations |
| Test | What it checks |
|---|---|
| Discovering users returns the same per-user activity fields as the users list | POST /api/users/discover used to return the raw discovery shape (missing activeShowCount/lastWatchedAt), so clicking Refresh silently dropped active viewers out of the Active section until the next reload |
PATCH-ing a user with no enabled field in the body leaves their enabled state unchanged |
PATCH /api/users/:id used to wrap the body's enabled in Boolean(...) unconditionally, turning a genuinely absent field into false and silently disabling the user; runs against live data and restores the original state in a finally block |
PATCH-ing a user with a non-boolean enabled value is rejected, not coerced |
Boolean(...) also accepted any truthy non-boolean (a string, an object) as true; the route now requires an actual boolean whenever the field is present at all |
| Test | What it checks |
|---|---|
| The shows-driven-by-user dialog reports nothing as active for a disabled viewer, even a recent watch | Must agree with the card's activeShowCount — a disabled viewer's watches can't keep anything expanded, so the dialog can't show the same shows as "Active" that the card just called inactive |
| The shows-driven-by-user dialog reports a recent watch as active for an enabled viewer | The positive case, so the negative one above is checking a real gate and not a query that always returns false |
| The shows-driven-by-user dialog reports a recent special (season 0) as inactive | Matches the countActiveShowsByUser card-level fix — specials don't expand a season, so they shouldn't read as "keeping this show active" here either |
Read-only. Safe to run against a live instance.
| Test | What it checks |
|---|---|
| History filters and page size render | Level filters (All / Info / Warning — no Error, which nothing writes) and the rows-per-page control render |
| History type filter is a fixed set of categories | All types, Monitoring, Cleanup, Shows, and Sync render, rather than one button per action string in the database |
| History category filter updates the URL | Selecting Cleanup writes ?category=cleanup |
| History level filter updates the URL | Selecting Warning writes ?level=warn |
Which layer to reach for — server test or Playwright — is covered in AGENTS.md
under Tests. Mechanically:
- Server tests: create a
*.test.tsfile undertests/server/and it is picked up bynpm run test:server. Tests should build their own temporary database rather than sharing state. - Playwright: create a
*.spec.tsfile undertests/playwright/; the saved session is loaded for each Chromium test. Use the live instance only and keep generated artifacts under the existing ignoredtests/paths.
When a test is agreed and written, add a row for it in the relevant table above.
For a local Docker verification:
docker build -t pacearr .
docker rm -f pacearr || true
docker run -d \
--name pacearr \
--network bridge \
-p 9302:9302 \
-v /opt/pacearr:/config \
--restart unless-stopped \
pacearr
timeout 90 sh -c 'until [ "$(docker inspect -f "{{.State.Health.Status}}" pacearr)" = healthy ]; do sleep 3; done' \
&& echo healthy || { docker logs pacearr 2>&1 | tail -20; false; }This waits for the HEALTHCHECK, which reads starting until its first check
passes, and prints healthy. If it prints logs instead, look for the
Pacearr listening startup line.
Depending on DooD network behaviour, curl http://127.0.0.1:9302/api/health
from inside the devcontainer may not reach the host-published port. Testing from
inside the container is the most reliable check:
docker exec pacearr node -e "fetch('http://127.0.0.1:9302/api/health').then(r => r.text()).then(console.log)"Then open http://localhost:9302 and smoke-test — use this after changes that
touch external integrations, cleanup, or Docker behaviour:
- Sign in with Plex OAuth or the manual token fallback
- Configure the Plex server URL and machine identifier
- Configure the Sonarr base URL and API key
- Optionally configure Tautulli and save settings
- Open Shows and confirm Sonarr series load
- Enroll a known show and confirm Pacearr applies the all-season-pilot baseline
- Run Import History and confirm relevant SxxE01 history expands seasons
- Run Check Sessions while a Plex episode is active and confirm live expansion
- Run inactive reset only when you are comfortable with cleanup/deletion behaviour
This section needs Docker. See "Where You're Running" in AGENTS.md — where it
is unavailable, say so rather than substituting a workspace check.