Tasks 005 — GW2 API v2 client
Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.
Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.
Global Constraints in plan.md apply to every task and are not repeated per task.
Build order is bottom-up: the four leaf units first (T1–T3), then the client that composes them (T4–T6), then the Nest wrapper that exposes it (T7), then close-out (T8). Every test is offline — Gw2Client always receives a fake fetchFn; a real fetch never runs in the suite (SC9).
T1 — TokenBucket gates requests to 300 burst / 5-per-second
Satisfies: R3, SC2, P1 #2.
Files: create apps/api/src/gw2/token-bucket.ts, apps/api/src/gw2/token-bucket.test.ts.
- [ ] RED:
SC2/P1#2: 300 take() calls resolve immediately, the 301st waits for refill. Withvi.useFakeTimers(), constructnew TokenBucket({ capacity: 300, refillPerSec: 5 }), fire 305take()calls; assert exactly 300 promises are resolved at t=0 and that the next resolves only afteradvanceTimersByTimeAsync(200)(one token every 200 ms), never exceeding the budget in any 1 s window. Watch it fail (TokenBucket is not defined) for the right reason. - [ ] GREEN: smallest
TokenBucket— a float token count, last-refill timestamp,take()returning a promise resolved now if a token is free, else scheduled viasetTimeoutat the refill interval. - [ ] REFACTOR: only with the test green.
- [ ] Teeth: set
capacitytoInfinityin the impl; the 301st-waits assertion must fail. Restore. - [ ] Commit:
api: add TokenBucket for GW2 rate budget (R3, SC2).
Verified by: token-bucket.test.ts › SC2/P1#2: ….
T2 — BoundedCache<V> expires by TTL and evicts oldest at the cap
Satisfies: R6, SC5 (cache mechanism), SC6.
Files: create apps/api/src/gw2/bounded-cache.ts, apps/api/src/gw2/bounded-cache.test.ts.
- [ ] RED: three tests under fake timers —
SC5: an entry with a 60_000 ms TTL is a miss after 60 s(set,advanceTimersByTimeAsync(60_001), expectgetundefined);R6: a no-TTL entry never expires;SC6: setting past the size cap evicts the oldest key(cap=2, insert 3 keys, assertsize === 2and the first key is gone). Watch them fail. - [ ] GREEN:
BoundedCache<V>over aMap,set(key, value, ttlMs?)storing an expiry;getdeletes and misses on expiry; onsetbeyondmaxEntries, delete the oldest (Mapinsertion order —this.store.keys().next().value). Exposesize. - [ ] REFACTOR: only with the test green.
- [ ] Teeth: disable the eviction branch;
SC6must fail. Restore. - [ ] Commit:
api: add BoundedCache with TTL + size cap (R6, SC6).
Verified by: bounded-cache.test.ts › SC5: …, SC6: ….
T3 — Zod schemas, typed errors, and captured fixtures
Satisfies: R7, SC7, P1 #6.
Files: create apps/api/src/gw2/gw2.schemas.ts, apps/api/src/gw2/gw2.errors.ts, apps/api/src/gw2/gw2.schemas.test.ts, and apps/api/src/gw2/__fixtures__/{items,recipes,recipe-search,prices}.json.
- [ ] Capture fixtures (setup for this task, not throwaway): save real responses —
items.jsonfrom/v2/items?ids=19721,19685,recipes.jsonfrom/v2/recipes?ids=1,2,recipe-search.jsonfrom/v2/recipes/search?output=46742,prices.jsonfrom/v2/commerce/prices?ids=19721,19685. Shapes are pinned inplan.md› Data & contracts. - [ ] RED: in
gw2.schemas.test.ts—SC7/P1#6: each fixture parses to the depended-on fields(item/recipe/search/price against their schema);SC7: a fixture with a required field deleted throws Gw2ValidationError;SC7: a fixture with an extra unknown field parses and the extra is absent from the result. Watch them fail (schemas/errors undefined). - [ ] GREEN: define
Gw2ValidationError,Gw2RateLimitError,Gw2RequestErroringw2.errors.ts(eachextends Error, setsname). Ingw2.schemas.tsdefineGw2ItemSchema,Gw2RecipeSchema,Gw2RecipeSearchSchema(z.array(z.number())),Gw2PriceSchemawith only the plan's fields, plus aparseOrThrow(schema, data)helper that wrapsschema.parseand rethrows Zod failures asGw2ValidationError. Export inferred typesGw2Item,Gw2Recipe,Gw2Price. - [ ] REFACTOR: only with the test green.
- [ ] Teeth: make a schema field
z.unknown(); the mistyped-field test must stop throwing. Restore. - [ ] Commit:
api: add GW2 Zod schemas, typed errors, fixtures (R7, SC7).
Verified by: gw2.schemas.test.ts › SC7/P1#6: ….
T4 — Gw2Client reads: batch ≤199, budget, validate, reassemble
Satisfies: R2, R5, SC3, SC9, P1 #3, and F4 (206/404) from research.md.
Files: create apps/api/src/gw2/gw2-client.ts, apps/api/src/gw2/gw2-client.test.ts.
- [ ] RED: with an injected
fetchFn(avi.fn()returningResponse-like objects from fixtures) —SC3/P1#3: items() of 250 ids issues ⌈250/199⌉ = 2 fetches(assertfetchFn.mock.calls.lengthand that each URL'sids=holds ≤199);F4: a 206 response omits a missing id from the result, no throw;F4: a 404 chunk contributes nothing, no throw;SC9: no test supplies a real fetch. Watch them fail. - [ ] GREEN:
Gw2Clientconstructor{ fetchFn = globalThis.fetch, baseUrl = 'https://api.guildwars2.com/v2', bucket }. A privategetByIds(path, schema, ids)that chunks to ≤199,await bucket.take()before each fetch, treats200/206as success and404as empty, callsparseOrThrowon the body, and reassembles in requested order (omitting absent ids). Publicitems,recipes,pricesdelegate to it;searchRecipes({ input?, output? })fetches the single query URL and parsesGw2RecipeSearchSchema. Non-ok, non-404, non-429 →Gw2RequestError. - [ ] REFACTOR: only with the test green.
- [ ] Teeth: raise the chunk size to 500; the
SC3two-fetch assertion must fail. Restore to 199. - [ ] Commit:
api: add Gw2Client batched reads with validation (R2, R5, SC3).
Verified by: gw2-client.test.ts › SC3/P1#3: …, F4: ….
T5 — Gw2Client caching: static hard, prices 60 s
Satisfies: R6, SC4, SC5, P1 #4, P1 #5.
Files: modify apps/api/src/gw2/gw2-client.ts; add tests to apps/api/src/gw2/gw2-client.test.ts.
- [ ] RED:
SC4/P1#4: items() for the same id twice issues one fetch(second call, assertfetchFncalled once);SC5/P1#5: prices() for an id twice within 60 s issues one fetch, and a third call after advancing past 60 s issues a second fetch(fake timers). Watch them fail (cache not yet wired — currently two fetches). - [ ] GREEN: give
Gw2ClientaBoundedCacheper data class (static, price). IngetByIds, partition requested ids into cache hits and misses, fetch only misses, then cache each returned id — static with no TTL, prices with 60_000 ms.searchRecipescaches by its query key with no TTL. - [ ] REFACTOR: only with the test green.
- [ ] Teeth: give the price cache a no-TTL store; the
SC5after-60 s second-fetch assertion must fail. Restore the 60 s TTL. - [ ] Commit:
api: cache GW2 reads — static hard, prices 60s (R6, SC4, SC5).
Verified by: gw2-client.test.ts › SC4/P1#4: …, SC5/P1#5: ….
T6 — Gw2Client 429 handling: bounded backoff, opportunistic Retry-After
Satisfies: R4, SC8, P1 #7.
Files: modify apps/api/src/gw2/gw2-client.ts; add tests to apps/api/src/gw2/gw2-client.test.ts.
- [ ] RED: under fake timers —
SC8/P1#7: a 429 then a 200 retries and returns the success(assert two fetches, resolved value from the 200);SC8: 429 on every attempt within the bound throws Gw2RateLimitError;R4: a 429 carrying Retry-After: 1 waits ~1 s before retrying(assert the retry fires only afteradvanceTimersByTimeAsync(1000)). Watch them fail. - [ ] GREEN: wrap the per-chunk fetch in a bounded retry (max 4 attempts): on
429, waitRetry-Afterseconds if the header is present, else base 250 ms exponential backoff with jitter, viasetTimeout; exhausting attempts throwsGw2RateLimitError. Backoff is primary;Retry-Afteronly honored opportunistically (research V2). - [ ] REFACTOR: only with the test green.
- [ ] Teeth: set max attempts to 1; the
429-then-200success test must fail. Restore to 4. - [ ] Commit:
api: retry GW2 429s with bounded backoff (R4, SC8).
Verified by: gw2-client.test.ts › SC8/P1#7: …, R4: ….
T7 — Gw2Service + Gw2Module, wired into the app as a singleton
Satisfies: R1, R10, SC1, P1 #1.
Files: create apps/api/src/gw2/gw2.service.ts, apps/api/src/gw2/gw2.module.ts, apps/api/src/gw2/gw2.service.test.ts; modify apps/api/src/app.module.ts.
- [ ] RED:
gw2.service.test.ts—SC1/P1#1: Gw2Service resolves from a compiled Nest module and is a singleton. Build viaTest.createTestingModule({ imports: [Gw2Module] }).compile()(mirroringhealth.controller.test.ts); assertget(Gw2Service)is defined and twogetcalls return the same instance. Watch it fail. - [ ] GREEN:
Gw2Service—@Injectable()constructing oneGw2Client(defaultfetch, aTokenBucketat 300/5) and delegatingitems/recipes/searchRecipes/prices.Gw2Moduleprovides and exports it. AddGw2ModuletoAppModule.imports(one line). - [ ] REFACTOR: only with the test green.
- [ ] Teeth: temporarily scope the provider transient; the singleton-identity assertion must fail. Restore default (singleton) scope.
- [ ] Confirm R10:
git statusshows no change underapps/web, andapps/api/openapi.jsonis untouched (no route was added). - [ ] Commit:
api: expose Gw2Service via Gw2Module, wire into AppModule (R1, SC1).
Verified by: gw2.service.test.ts › SC1/P1#1: …; git status clean of web/contract files.
T8 — Close-out: traceability + full verification
Satisfies: the definition of done in CLAUDE.md (every criterion traced to a named test).
Files: modify specs/005-gw2-api-client/spec.md (traceability table only).
- [ ] Fill
spec.md's traceability table — one row perP1 #nandSCmapping to the exact test name from T1–T7 (a transcription, since each test was named for its criterion). - [ ] Run the full gate from the repo root and paste the passing output into the review:
pnpm typecheck && pnpm test && pnpm lint && pnpm build. All green, noany, no escape hatches. - [ ] Note in
## Notesanything that turned out differently fromplan.md, for step 6 graduation. - [ ] Commit:
specs: 005 fill traceability table for GW2 API client.
Verified by: pnpm typecheck && pnpm test && pnpm lint && pnpm build green from the root; every row of the traceability table names a passing test.
Notes
Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.
- Graduation candidates already known (from
research.md): the effective 199-id cap,206/404partial semantics, the600-header trap,Retry-Afterbeing undocumented → adocs/architecture/gw2-api.mdat step 6. - Implementation finding (T3): in Zod v4, a required key must be present regardless of its value schema — a bare
z.unknown()field still fails parsing when the key is absent;.optional()is what disables the presence check. Worth a line indocs/architecture/typescript.mdif it bites again. - Design note: caching is per-id (items/recipes/prices) and per-query (searchRecipes); a
429never poisons the cache (it throws before any write), and absent ids are omitted, never negatively cached.searchRecipesrejects a query that isn't exactly one ofinput/output. - All 8 tasks landed clean through per-task spec+quality review and a final whole-branch review (opus); only one should-fix (a tautological SC9 test) surfaced and was corrected. Full gate green: typecheck + 126 tests + lint (gw2 code warning-free) + build.