Skip to content

One Trakt fake, checked against recorded Trakt behavior - #218

Merged
arun279 merged 13 commits into
feat/expo-nativefrom
test/one-trakt-fake
Oct 24, 2026
Merged

arun279 merged 13 commits into
feat/expo-nativefrom
test/one-trakt-fake

Conversation

@arun279

@arun279 arun279 commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Why

Cue had three Trakt fakes: the socket server in scripts/mock-trakt, an in-process respond() transport built on it, and a third one inside read-budget.integration.test.ts. On top of those, 11 core test files wrote their own Trakt responses with MSW. They disagreed with each other and with Trakt. For example, a refresh rejection was a 401 where Trakt answers 400, a pending device poll had a JSON body where Trakt sends none, and rate limits were never enforced, so a pacing regression would pass. Only GET 200 shapes were checked against a recording, and a 404 on a recorded endpoint was skipped silently.

This PR replaces all of that with one fake in packages/core/test/trakt-fake. It follows recordings of real Trakt made on a throwaway account, and a conformance test fails whenever the fake disagrees with them.

What the fake does now

Behavior Before Now Real Trakt
Refresh with a token Trakt does not know 401 {"error":"invalid_grant"} (a fault profile) 400 {"error":"invalid_grant","error_description":"session not found"} Same as now (recorded)
Refresh token Reusable forever Rotates: each refresh answers once with a new pair, and the old access token keeps working Rotation and the old token working recorded; reuse not recorded
Access token 90 days, never checked 7 days, checked on the fake's clock; an unknown or expired token gets 401 Unauthorized as plain text 604800 s (recorded, documented); 401 body recorded
Revoke 200 {} 200 with an empty body; the refresh token dies at once, the access token about 10 minutes later Recorded
Device code interval: 1, verification URL on the fake interval: 6, https://auth.trakt.tv/activate Recorded
Pending device poll 400 {"error":"authorization_pending"} 400, empty body, no content type Recorded
Other device poll statuses None 404 unknown code, 409 already used, 410 after 600 s, 418 denied, 429 polled faster than the interval Documented
404 JSON {"error": ...} Empty text/plain body Recorded
Page size 6, 10, 12 or 100 per endpoint, mostly uncapped Default 100, capped at 250 (watched shows with progress at 100); an empty list has page count 0 Recorded
Rate limits Never enforced One authed POST a second, 500 GETs per 5 minutes; a 429 names the bucket in X-Ratelimit and sends Retry-After Documented
Watchlist write answer added{shows,movies}, 4 not_found lists Counts for movies, shows, seasons and episodes; 6 not_found lists; list{updated_at,item_count} Recorded
Watchlist activities Every write moved both types' watchlisted_at and lists.updated_at A show write moves shows.watchlisted_at, a movie write movies.watchlisted_at, both move watchlist.updated_at; lists.updated_at stays Recorded
Season mark with a missing season not_found.seasons always empty Echoes the whole show entry, seasons that applied included; updated replaces the earlier play's date Recorded
Removing a movie by its ids Removed one play Removes every play (deleted.movies: 2 with two plays) Recorded
Plays at the same minute Insertion order Later episode first Recorded
watched_at in one write The first item's for every play Each play's own: the episode's, then its season's, then its show's Accepted per item by the API; not compared by a recording
episode_type Absent On every episode under extended=full Recorded on calendar rows
Artwork strings http:// URLs to the fake Trakt-style paths without a scheme in tests; the served fake points at its own placeholder images Recorded shapes
Activity stamps Pushed at least 1 ms past the previous stamp The time of the write, cut to the second Recorded to the second
Device codes A new code replaced the previous one Each code stays valid for its own expires_in (600 s) Documented
Restarted shows Not modelled The watched row sends reset_at, and progress counts only plays after it The app's own schema notes this behavior; not recorded

The default account now also has a hidden show, a dropped show, a show watched only through its specials, a long runner numbered from its specials, a show numbered across seasons, a thinly catalogued show, a show whose progress snapshot predates its newest episode, and a played show whose next season drops all at once, so the calendar's grouped row shows up in the smoke screenshots. sized(...) still builds an account of a given size for the request budget, and emptyAccount() gives a test an account to fill with its own shows. A show can also override two watched-row fields the play counter cannot derive: a missing last_watched_at and a different aired_episodes.

What was deleted

  • scripts/mock-trakt: server.mjs, seed.mjs, faults.mjs, scale.mjs, journal.mjs and genres.mjs, with the __fault control, the seed and fault profiles, the authorization-code route and the CORS headers.
  • packages/core/test/harness: mock-server.ts, mock-trakt.test.ts, mock-behavior.test.ts, journal.test.ts, trakt-limits.ts, trakt-real-shapes.json and trakt-transcripts.json.
  • packages/core/test/data/_msw.ts and read-budget.integration.test.ts. Its cold-sync cases are now in scenario-budget.test.tsx. The cases about unusual watched rows (a restarted show, more plays than aired episodes, a row with no watched date) are in read-budget.test.ts and go through the fake. So does the case of 20 recently watched shows that are caught up, ahead of 80 with episodes left. It fails if the progress budget is cut before caught-up shows are filtered out. The broken progress body is a decode test.
  • The schema-fitted fixtures in trakt-endpoints.test.ts. Its deliberately broken bodies moved to trakt-tolerant-decoding.test.ts. That is now the only test that stubs fetch, and the fake's README says so. read-retry.test.tsx gets its 503 from the fake's fault helper.

Kept on purpose:

  • The placeholder image route, in serve.ts only. A simulator cannot load Trakt-style paths from a local fake, and the smoke screenshots need posters.
  • routes.test.ts. Its compile-time list of every read and write the app has fails to build when a new one is added, and the test fails when the fake has no route for it.

How conformance works

recordings.json holds two kinds of recording, both with no account values:

  • For every read the app makes: statuses, pagination headers, and per body path the JSON types, string classes, presence and empty arrays.
  • Transcripts of scenarios that write to an account and put it back: statuses, content types, body shapes, write counts, which activity stamps moved, page limits and orderings. The earlier transcripts are merged with the new recordings: OAuth, error bodies, page sizes, watchlist writes, the season mark, specials and a movie rewatch with Multiple Plays on and off.

conformance.test.ts drives the fake through the same reads and through scenarios.ts, on a clock spaced as the recording's request guard was. It fails on any path, type, class, status or header Trakt was never seen sending, on any 404 from a recorded read, and on any difference from a transcript. What cannot be recorded (token lifetime, the device statuses, the rate limits) follows docs.trakt.tv and developer.trakt.tv, and is tested in fake.test.ts.

Provenance is in the fake's README, per group in recordings.json. All of it came from a throwaway account through a paced, guarded recorder:

  • 2026-10-21: the read shapes, and the transcripts for marks, rewatches, unknown items, paging, hiding and dropping. These were made by an earlier version of scenarios.ts.
  • 2026-10-24: OAuth, errors, page sizes, the watchlist, the season mark, specials and the movie rewatch. A separate recorder captured whole exchanges, and these transcripts were reduced from them by hand.

None of the scenarios in scenarios.ts has been run against real Trakt as it stands, and this PR made no Trakt requests.

Mutation checks I ran, each reverted afterwards:

  • Refresh rejection as 401: conformance fails.
  • A show write moving the movie watchlist stamp: conformance fails.
  • Removing the related-shows route: conformance (404 on a recorded read) and routes.test.ts both fail.
  • Cutting the progress budget before filtering caught-up shows, filtering with < instead of !==, ranking an unstamped row first, and ignoring reset_at each fail one of the budget selection tests.
  • An extra GET in the cold sync fails the request total in scenario-budget. A shortened entry list fails the caught-up entry count. A rotated token the fake does not know fails the rotated-token check.
  • A newer device code clearing older ones fails the device code test. erasableSyntaxOnly rejects a parameter property.

Transports

  • Tests run the handlers in process. startTraktFake() wraps setupServer with onUnhandledRequest: "error", and fake.fetch uses getResponse for code that takes a fetch.
  • serve.ts is a small node:http adapter around the same handlers. It runs with plain node through Node 22's type stripping, answers on 127.0.0.1:8787, and keeps POST /__approve and POST /__reset. CI starts it in place of server.mjs, and pnpm fake:trakt replaces pnpm mock:trakt.

Lint gate

oxlint's no-restricted-imports rejects msw and msw/* outside packages/core/test/trakt-fake. I checked that it flags an MSW import in another test file.

App changes the recordings forced

  • None of the recordings exposed an app bug. The app reads a refresh rejection by its invalid_grant code, not its status, so Trakt's real 400 still ends the session.
  • Calendar: premieres are now told by Trakt's episode_type (series_premiere, season_premiere), falling back to episode 1 when a row has no type. A season numbered on from the last one, such as an anime whose second season starts at episode 62, now counts. I mutation-checked the filter both ways. Calendar entries carry the type, so the persisted cache buster is bumped.
  • TRAKT_API_BASE is no longer exported from oauth.ts and client.ts, since only tests imported it.

Rate limits: product issues to fix next, not in this PR

With Trakt's documented limit of 500 GETs per 5 minutes enforced, the heavy library in scenario-budget goes past it in all three launch scenarios (counts from the review):

Scenario GETs 429s
First sync 794 2
Cold launch 701 5
Warm launch on a fresh cache 569 4
  • Duplicate reads: part of that is reads made more than once in the same launch. The watchlist shows pages are read twice, the hidden and dropped lists three times, and last_activities twice.
  • Already known over budget: these scenarios were already marked over budget. scenario-budget now waits out each pause the way a person would, instead of failing to settle.
  • Browse rails: two screens asking for them at the same moment cost 6 GETs only when the responses are slow. The two reads that queue behind the read pool fetch again once the first ones finish. Otherwise they cost 4.

Net lines

Category Added Removed Net
Product (packages/core/src) 15 5 +10
Tests (packages/*/test, __tests__), including the fake 5,264 3,721 +1,543
Scripts, CI and config (scripts/mock-trakt, ci.yml, knip.json, .oxlintrc.json, package.json, biome.jsonc, tsconfig.test.json) 27 2,774 -2,747
Recordings (one minified JSON line replaces two files) 1 375 -374
Docs (README and the fake's README) 40 10 +30
Total 5,347 6,885 -1,538

Test lines grow because the fake's seed, bodies and writes moved from scripts/mock-trakt into packages/core/test/trakt-fake.

Harness size, before and after:

  • The harness itself (scripts/mock-trakt, packages/core/test/harness and _msw.ts before; packages/core/test/trakt-fake without its README, and packages/core/test/harness after): 6,421 lines before, 5,642 after.
  • With the core test files that wrote their own Trakt responses (the 13 data test files plus read-budget.test.ts): about 9,250 lines before, about 7,600 after.

The fake lives in packages/core/test/trakt-fake: the account, its read
bodies, the write endpoints, OAuth sessions, the rate limiter and every
route the app calls, as MSW handlers. Tests can run it in process, and
serve.ts puts the same handlers behind a small node:http server for the
smoke flows.

It follows recordings of real Trakt made on a throwaway account:
refresh rejections answer 400 invalid_grant, a pending device poll
answers 400 with an empty body, 404 and 401 bodies are plain text, pages
default to 100 rows and cap at 250, watchlist writes move only their own
type's activity and answer with the list's item count, a season mark
splits added from updated and echoes a missing season's whole entry, and
removing a movie removes every play of it. Behavior that cannot be
recorded follows Trakt's documentation: 7-day tokens, the device token
statuses, and one authed POST per second and 500 GETs per 5 minutes on
the fake's clock.

recordings.json merges the recorded read shapes, the earlier behavior
transcripts and the new recordings. conformance.test.ts drives the fake
through scenarios.ts and compares statuses, content types, body shapes
and write semantics with them, and fails when a recorded read answers
404.
The tests that wrote their own MSW Trakt responses now read the fake's
default account, check what the app sent through the fake's request log,
and get failures from its one-shot helpers: fail, hold and drop. Bodies
that are deliberately off-spec stay as decode tests over a stubbed fetch.

The fake's own internals tests, the request journal, the in-process
server wrapper and the third fake inside read-budget.integration are
gone. That file's cold-sync cases that the fake can express moved into
scenario-budget; the ones about watched rows the fake does not produce
(a restarted show, more plays than aired episodes, an unstamped row, a
broken progress body) are unit tests in read-budget.test.ts. A short
fake.test.ts covers what the fake does from Trakt's documentation: token
lifetime, the device token statuses and the rate limits.

The fake now stamps each play with its own watched_at, can peek at a read
without spending a request, and resets to either Multiple Plays setting.

scenario-budget waits out a rate-limit pause the way a person would. A
heavy library's first sync sends more than the 500 GETs Trakt allows in
five minutes, so with the limit enforced it pauses before it settles;
that scenario was already marked over budget.

TRAKT_API_BASE is no longer exported from oauth.ts and client.ts, since
no test imports it. The persisted cache shape witness is reseeded for
that; no persisted shape changed.
The smoke jobs start the fake with `node packages/core/test/trakt-fake/serve.ts`,
which Node 22 runs directly by stripping the types. It answers on
127.0.0.1:8787 as before and keeps the two controls the flows and the
iOS re-run use, POST /__approve and POST /__reset. Its artwork points at
three placeholder images it serves itself, so screenshots still show
posters without reaching Trakt's image host.

The old server, its fault, scale and journal modules and its seed are
deleted. `pnpm fake:trakt` replaces `pnpm mock:trakt`, and the README
describes the one fake and how it is checked.
oxlint's no-restricted-imports now rejects `msw` and `msw/*` everywhere
except packages/core/test/trakt-fake, so a test cannot grow its own
hand-written Trakt responses again; it has to go through the fake.
For a show you have never played, the Calendar and On the way list only
premieres. A premiere used to mean episode 1 of a season. It now means
an episode Trakt types as series_premiere or season_premiere, which
Trakt sends on every calendar row (seen on a real account). A season
numbered on from the last one, such as an anime whose second season
starts at episode 62, now counts. Episode 1 is still the rule when a row
carries no type.

Calendar entries keep the type, so the persisted cache buster is bumped.
The fake now models Trakt's "restart show": a show can carry a reset
date, its watched row sends reset_at, and progress counts only plays
after it. A show spec can also override two watched-row fields the play
counter cannot derive, a missing last_watched_at and a different
aired_episodes, so the cases about unusual watched rows read the fake
instead of a stub fetch.

This also restores the case lost when read-budget.integration was
folded: 20 recently watched shows that are caught up, ahead of 80 with
episodes left. The cold sync still spends all 60 progress reads on shows
with episodes left; it fails if the budget is cut before caught-up shows
are filtered out.

The broken progress body case moved to the tolerant decoding tests,
which are the only ones that stub fetch with off-spec bodies.
…ones

Real Trakt stamps each activity with the time of the write, cut to the second. The fake used to push every stamp at least 1 ms past the previous one; no test depended on that.
A new device code used to replace the previous one, so polling an
earlier code answered 404. Each code now stays valid until its own
expires_in runs out on the fake's clock, and approving or denying acts
on the latest code, the one on screen.

fake.test.ts drops the case that only checked the fault helper answered
what it was told.
The core test tsconfig turns on erasableSyntaxOnly, since serve.ts and
the modules it imports run under Node's type stripping. The scenario
recorder's constructor no longer uses parameter properties, its
pass-through call method is gone, and a stale doc comment is removed.
The file's header no longer claims the scenarios were run against real
Trakt as written.
The fake's own README now describes its modules, the rules for tests
that use it (only the tolerant decoding tests stub fetch, with off-spec
bodies), how conformance works, and the provenance of each group in
recordings.json: the date, the throwaway account and the paced,
guarded recorder. The repository README points to it.
@github-actions

Copy link
Copy Markdown

Pull request footprint

measurement base to head
Product code lines in core/src, native/src, native/app, and native/modules +6
Test lines in test, packages/*/{test,__tests__,e2e}, and .maestro +1074
CI and tooling lines in .github and scripts -2595
Product comment lines identified by a comment prefix +2
JavaScript bundle at head size
Expo ios bundle 5623 kB
Expo android bundle 5662 kB

@arun279
arun279 merged commit a472d31 into feat/expo-native Oct 24, 2026
10 checks passed
@arun279
arun279 deleted the test/one-trakt-fake branch October 24, 2026 04:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant