// Stateful doubles of App Store Connect and Google Play for the idempotency tests (tests/idempotency.test.ts): what a // command creates is there on the next read, so running a command twice shows whether the second run writes again. // Shapes follow the recorded answers in store-mock.ts and the APIs' documented JSON:API / androidpublisher formats; // every id and name is a placeholder. Faults can be injected per request (a 5xx after the write was applied = an // unclear answer; a 5xx before it; a failing upload part), and new resources can be hidden from list reads for a // while (the stores' list lag right after a create). Nothing here reaches the network. import { json, type Call } from './store-mock'; import { APPLE_LIVE_REJECTED, resolveOp } from '../../cli/api/client'; export type R = { type: string; id: string; attributes: Record; rels: Record; order: number; hiddenReads?: number }; export interface Fault { method: string; match: RegExp; when: 'before' | 'after'; status?: number; times?: number; code?: string; detail?: string } const ASC = 'https://api.appstoreconnect.apple.com'; const UPLOAD_HOST = 'https://upload.example.com/'; /** list relationship -> resource type of its items (parent type / relationship name); `one` for to-one relationships. */ const LISTS: Record = { 'apps/inAppPurchasesV2': { type: 'inAppPurchases' }, 'inAppPurchases/inAppPurchaseLocalizations': { type: 'inAppPurchaseLocalizations' }, 'inAppPurchases/iapPriceSchedule': { type: 'inAppPurchasePriceSchedules', one: true }, 'inAppPurchases/inAppPurchaseAvailability': { type: 'inAppPurchaseAvailabilities', one: true }, 'inAppPurchases/appStoreReviewScreenshot': { type: 'inAppPurchaseAppStoreReviewScreenshots', one: true }, 'inAppPurchases/offerCodes': { type: 'inAppPurchaseOfferCodes' }, 'inAppPurchaseOfferCodes/customCodes': { type: 'inAppPurchaseOfferCodeCustomCodes' }, 'inAppPurchaseOfferCodes/oneTimeUseCodes': { type: 'inAppPurchaseOfferCodeOneTimeUseCodes' }, 'apps/appStoreVersions': { type: 'appStoreVersions' }, 'appStoreVersions/appStoreVersionLocalizations': { type: 'appStoreVersionLocalizations' }, 'appStoreVersions/appStoreReviewDetail': { type: 'appStoreReviewDetails', one: true }, 'appStoreVersions/appStoreVersionPhasedRelease': { type: 'appStoreVersionPhasedReleases', one: true }, 'appStoreVersionLocalizations/appScreenshotSets': { type: 'appScreenshotSets' }, 'appStoreVersionLocalizations/appPreviewSets': { type: 'appPreviewSets' }, 'appScreenshotSets/appScreenshots': { type: 'appScreenshots' }, 'appPreviewSets/appPreviews': { type: 'appPreviews' }, 'apps/appCustomProductPages': { type: 'appCustomProductPages' }, 'appCustomProductPages/appCustomProductPageVersions': { type: 'appCustomProductPageVersions' }, 'appCustomProductPageVersions/appCustomProductPageLocalizations': { type: 'appCustomProductPageLocalizations' }, 'appCustomProductPageLocalizations/appScreenshotSets': { type: 'appScreenshotSets' }, 'apps/appEvents': { type: 'appEvents' }, 'appEvents/localizations': { type: 'appEventLocalizations' }, 'apps/betaGroups': { type: 'betaGroups' }, 'apps/subscriptionGroups': { type: 'subscriptionGroups' }, 'subscriptionGroups/subscriptionGroupLocalizations': { type: 'subscriptionGroupLocalizations' }, 'subscriptionGroups/subscriptions': { type: 'subscriptions' }, 'subscriptions/subscriptionLocalizations': { type: 'subscriptionLocalizations' }, 'subscriptions/subscriptionAvailability': { type: 'subscriptionAvailabilities', one: true }, 'subscriptions/appStoreReviewScreenshot': { type: 'subscriptionAppStoreReviewScreenshots', one: true }, 'subscriptions/promotionalOffers': { type: 'subscriptionPromotionalOffers' }, 'subscriptions/offerCodes': { type: 'subscriptionOfferCodes' }, 'subscriptions/prices': { type: 'subscriptionPrices' }, 'subscriptions/introductoryOffers': { type: 'subscriptionIntroductoryOffers' }, 'subscriptionOfferCodes/customCodes': { type: 'subscriptionOfferCodeCustomCodes' }, 'apps/appStoreVersionExperimentsV2': { type: 'appStoreVersionExperiments' }, 'appStoreVersionExperiments/appStoreVersionExperimentTreatments': { type: 'appStoreVersionExperimentTreatments' }, 'appStoreVersionExperimentTreatments/appStoreVersionExperimentTreatmentLocalizations': { type: 'appStoreVersionExperimentTreatmentLocalizations' }, 'appStoreVersionExperimentTreatmentLocalizations/appScreenshotSets': { type: 'appScreenshotSets' }, 'reviewSubmissions/items': { type: 'reviewSubmissionItems' }, 'apps/reviewSubmissions': { type: 'reviewSubmissions' }, 'apps/buildUploads': { type: 'buildUploads' }, 'inAppPurchases/versions': { type: 'inAppPurchaseVersions' }, 'subscriptions/versions': { type: 'subscriptionVersions' }, 'subscriptionGroups/versions': { type: 'subscriptionGroupVersions' }, 'appStoreVersions/build': { type: 'builds', one: true }, 'apps/appInfos': { type: 'appInfos' }, 'appInfos/appInfoLocalizations': { type: 'appInfoLocalizations' }, 'appInfos/ageRatingDeclaration': { type: 'ageRatingDeclarations', one: true }, 'apps/appPriceSchedule': { type: 'appPriceSchedules', one: true }, 'apps/appPricePoints': { type: 'appPricePoints' }, 'apps/appAvailabilityV2': { type: 'appAvailabilities', one: true }, 'appAvailabilities/territoryAvailabilities': { type: 'territoryAvailabilities' }, 'apps/promotedPurchases': { type: 'promotedPurchases' }, 'subscriptions/winBackOffers': { type: 'winBackOffers' }, 'betaGroups/betaTesters': { type: 'betaTesters' }, 'apps/analyticsReportRequests': { type: 'analyticsReportRequests' }, }; /** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */ const UNIQUE: Record string> = { inAppPurchases: r => String(r.attributes.productId), inAppPurchaseLocalizations: r => `${r.rels.inAppPurchaseV2}|${r.attributes.locale}`, appScreenshotSets: r => `${Object.values(r.rels).join()}|${r.attributes.screenshotDisplayType}`, appPreviewSets: r => `${Object.values(r.rels).join()}|${r.attributes.previewType}`, betaGroups: r => String(r.attributes.name), subscriptionGroups: r => String(r.attributes.referenceName), subscriptions: r => String(r.attributes.productId), subscriptionLocalizations: r => `${r.rels.subscription}|${r.attributes.locale}`, subscriptionGroupLocalizations: r => `${r.rels.subscriptionGroup}|${r.attributes.locale}`, appCustomProductPageLocalizations: r => `${r.rels.appCustomProductPageVersion}|${r.attributes.locale}`, appEventLocalizations: r => `${r.rels.appEvent}|${r.attributes.locale}`, appStoreReviewDetails: r => String(r.rels.appStoreVersion), devices: r => String(r.attributes.udid).toLowerCase(), reviewSubmissions: r => `${r.rels.app}|${r.attributes.state}`, appStoreVersionPhasedReleases: r => String(r.rels.appStoreVersion), bundleIds: r => String(r.attributes.identifier), appStoreVersions: r => `${r.rels.app}|${r.attributes.platform}|${r.attributes.versionString}`, appInfoLocalizations: r => `${r.rels.appInfo}|${r.attributes.locale}`, appStoreVersionLocalizations: r => `${r.rels.appStoreVersion}|${r.attributes.locale}`, promotedPurchases: r => String(r.rels.inAppPurchaseV2 ?? r.rels.subscription), winBackOffers: r => `${r.rels.subscription}|${r.attributes.offerId}`, betaTesters: r => String(r.attributes.email).toLowerCase(), }; const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$/; /** Attributes a new resource of a type starts with. */ const DEFAULTS: Record> = { inAppPurchases: { state: 'MISSING_METADATA' }, subscriptions: { state: 'MISSING_METADATA' }, reviewSubmissions: { state: 'READY_FOR_REVIEW' }, appCustomProductPageVersions: { state: 'PREPARE_FOR_SUBMISSION' }, devices: { status: 'ENABLED', deviceClass: 'IPHONE' }, appStoreVersionPhasedReleases: { phasedReleaseState: 'INACTIVE' }, appEvents: { eventState: 'DRAFT' }, appStoreVersionExperiments: { state: 'PREPARE_FOR_SUBMISSION' }, appStoreVersions: { appVersionState: 'PREPARE_FOR_SUBMISSION' }, betaTesters: { state: 'NOT_INVITED' }, }; const singular = (t: string): string => t.replace(/s$/, ''); export class FakeAsc { res = new Map(); next = 1; /** new resources of these types stay out of list reads for that many reads */ lag: Record = {}; /** every uploaded part, by asset id */ parts: string[] = []; constructor(readonly appId = '6400000101') { this.put({ type: 'apps', id: appId, attributes: { bundleId: 'com.example.notes', name: 'Example Notes' }, rels: {} }); for (const t of ['USA', 'DEU', 'TUR']) this.put({ type: 'territories', id: t, attributes: { currency: t === 'USA' ? 'USD' : t === 'DEU' ? 'EUR' : 'TRY' }, rels: {} }); } put(r: Omit): R { const x = { ...r, order: this.next++ } as R; this.res.set(x.id, x); return x; } all(type: string): R[] { return [...this.res.values()].filter(r => r.type === type).sort((a, b) => a.order - b.order); } view(r: R): Record { const relationships: Record = {}; for (const [k, v] of Object.entries(r.rels)) relationships[k] = { data: Array.isArray(v) ? v.map(id => ({ type: this.res.get(id)?.type, id })) : { type: this.res.get(v)?.type, id: v } }; return { type: r.type, id: r.id, attributes: r.attributes, relationships }; } children(parent: string, type: string): R[] { return this.all(type).filter(r => Object.values(r.rels).some(v => (Array.isArray(v) ? v.includes(parent) : v === parent))); } private visible(rs: R[]): R[] { return rs.filter(r => { if (r.hiddenReads && r.hiddenReads > 0) { r.hiddenReads--; return false; } return true; }); } private filtered(rs: R[], q: URLSearchParams): R[] { let out = rs; for (const [k, v] of q) { const m = /^filter\[([\w.]+)\]$/.exec(k); if (!m) continue; const f = m[1]!, vals = v.split(','); if (f.includes('.')) continue; out = out.filter(r => (f in r.attributes ? vals.includes(String(r.attributes[f])) : Object.entries(r.rels).some(([rk, rv]) => (rk === f || rk === singular(f)) && vals.some(x => (Array.isArray(rv) ? rv.includes(x) : rv === x))))); } return out; } create(body: { data: { type: string; attributes?: Record; relationships?: Record }; included?: { type: string; id: string; attributes?: Record; relationships?: Record }[] }): R | Response { const temp = new Map(); const idFor = (t: string) => `${singular(t).replace(/[A-Z]/g, c => c.toLowerCase()).slice(0, 12)}-${this.next}`; for (const inc of body.included ?? []) temp.set(inc.id, `${idFor(inc.type)}-${temp.size}`); const relsOf = (rel?: Record): Record => Object.fromEntries(Object.entries(rel ?? {}).filter(([, v]) => v?.data).map(([k, v]) => [k, Array.isArray(v.data) ? (v.data as { id: string }[]).map(d => temp.get(d.id) ?? d.id) : temp.get((v.data as { id: string }).id) ?? (v.data as { id: string }).id])); const d = body.data; const main: Omit = { type: d.type, id: idFor(d.type), attributes: { ...DEFAULTS[d.type], ...(d.attributes ?? {}) }, rels: relsOf(d.relationships) }; // App Store Connect keeps an item in one open review submission only if (d.type === 'reviewSubmissionItems') { const [kind, vid] = Object.entries(main.rels).find(([k]) => k !== 'reviewSubmission') ?? []; const holder = this.all('reviewSubmissionItems').find(r => r.rels[kind ?? ''] === vid && r.rels.reviewSubmission !== main.rels.reviewSubmission && !['COMPLETE', 'CANCELING'].includes(String(this.res.get(String(r.rels.reviewSubmission))?.attributes.state))); if (holder) return json({ errors: [{ status: '409', code: 'STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION', title: 'The item is already part of another submission', detail: `${this.res.get(String(vid))?.type ?? kind} with id ${vid} was already added to another reviewSubmission with id ${holder.rels.reviewSubmission}` }] }, 409); // seen live (2026-10-12): a product version left DEVELOPER_REJECTED by a canceled submission is accepted again as // it is (201, item READY_FOR_REVIEW); an APPROVED one is not (it needs a new version first) const v = this.res.get(String(vid)); if (v && /^(inAppPurchase|subscription|subscriptionGroup)Versions$/.test(v.type) && v.attributes.state === 'APPROVED') return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: `${v.type} with id ${v.id} is not in a valid state to be submitted` }] }, 409); if (v && v.attributes.state === 'DEVELOPER_REJECTED' && v.type !== 'appStoreVersions') v.attributes.state = 'READY_FOR_REVIEW'; main.attributes.state ??= 'READY_FOR_REVIEW'; } const u = UNIQUE[d.type]; if (u && this.all(d.type).some(r => u(r) === u(main as R))) return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409); if (ASSETS.test(d.type) && d.attributes?.fileName) { main.attributes.uploadOperations = [{ method: 'PUT', url: `${UPLOAD_HOST}${main.id}`, offset: 0, length: Number(d.attributes.fileSize ?? 0), requestHeaders: [{ name: 'Content-Type', value: 'image/png' }] }]; main.attributes.assetDeliveryState = { state: 'AWAITING_UPLOAD' }; } // a tester belongs to the app of its groups (GET /v1/betaTesters?filter[apps]=) if (d.type === 'betaTesters') { const g = [main.rels.betaGroups].flat()[0]; const app = g ? this.res.get(String(g))?.rels.app : undefined; if (app) main.rels.app = app; } const r = this.put(main); if (this.lag[d.type]) r.hiddenReads = this.lag[d.type]; for (const inc of body.included ?? []) { // included resources are linked back to whatever points at them this.put({ type: inc.type, id: temp.get(inc.id)!, attributes: { ...DEFAULTS[inc.type], ...(inc.attributes ?? {}) }, rels: relsOf(inc.relationships) }); } for (const holder of [r, ...(body.included ?? []).map(i => this.res.get(temp.get(i.id)!)!)]) { for (const [k, v] of Object.entries(holder.rels)) for (const cid of Array.isArray(v) ? v : [v]) { const child = this.res.get(cid); if (child && [...temp.values()].includes(cid) && !child.rels[singular(holder.type)]) child.rels[singular(holder.type)] = holder.id; void k; } } return r; } /** * App Store Connect answers 400 PARAMETER_ERROR.ILLEGAL to a query parameter the operation does not take, and (seen * live) to some the spec declares (APPLE_LIVE_REJECTED: filter[builds] on GET /v1/betaGroups). The fake does the same, * so a command that builds such a GET fails here as it fails live. */ illegalQuery(u: URL): Response | null { let op; try { op = resolveOp('apple', 'GET', u.pathname).op; } catch { return null; } const declared = new Set(op.query.map(q => q.name)); for (const k of new Set(u.searchParams.keys())) { if (declared.has(k) && !APPLE_LIVE_REJECTED[op.id]?.[k]) continue; return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '400', code: 'PARAMETER_ERROR.ILLEGAL', title: 'A parameter has an invalid value', detail: `'${k}' is not a valid ${/^filter\[/.test(k) ? 'filter type' : 'parameter'}`, source: { parameter: k } }] }, 400); } return null; } handle(method: string, path: string, body: unknown): Response { const u = new URL(ASC + path); const parts = u.pathname.split('/').filter(Boolean); // v1, type, id?, rel? const [, type, id, rel, rel2] = parts; if (method === 'GET') { const bad = this.illegalQuery(u); if (bad) return bad; if (type === 'territories') return json({ data: this.all('territories').map(r => this.view(r)), links: {} }); if (rel === 'pricePoints') { const t = u.searchParams.get('filter[territory]') ?? 'USA'; return json({ data: [0.99, 1.99, 2.99, 4.99, 9.99].map(p => ({ type: 'inAppPurchasePricePoints', id: `pp-${t}-${p}`, attributes: { customerPrice: String(p) }, relationships: { territory: { data: { type: 'territories', id: t } } } })), included: [{ type: 'territories', id: t, attributes: { currency: 'USD' } }], links: {} }); } if (rel === 'equalizations') { // every other territory at the same price (enough for the comparisons the commands make) const [, , price] = id!.split('-'); return json({ data: ['DEU', 'TUR'].map(t => ({ type: 'subscriptionPricePoints', id: `pp-${t}-${price}`, attributes: { customerPrice: price }, relationships: { territory: { data: { type: 'territories', id: t } } } })), links: {} }); } if (!id) return json({ data: this.visible(this.filtered(this.all(type!), u.searchParams)).map(r => this.view(r)), links: {} }); const self = this.res.get(id); if (!rel) return self && self.type === type ? json({ data: this.view(self) }) : json({ errors: [{ status: '404' }] }, 404); if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) }); const l = LISTS[`${type}/${rel}`]; if (!l) return json({ errors: [{ status: '599', detail: `fake: no list ${type}/${rel}` }] }, 599); const kids = this.filtered(this.children(id, l.type), u.searchParams); if (l.one) { const one = kids[0]; if (!one) return json({ errors: [{ status: '404' }] }, 404); const included = (u.searchParams.get('include') ?? '').split(',').filter(Boolean).flatMap(() => [...this.res.values()].filter(r => Object.values(r.rels).some(v => v === one.id || (Array.isArray(v) && v.includes(one.id))) && r.id !== one.id).map(r => this.view(r))); return json({ data: this.view(one), included }); } const shown = this.visible(kids); // price lists carry their price points (customerPrice from the placeholder id pp--) const points = [...new Set(shown.map(r => r.rels.subscriptionPricePoint ?? r.rels.inAppPurchasePricePoint).filter((x): x is string => typeof x === 'string'))]; const included = points.map(pid => ({ type: 'subscriptionPricePoints', id: pid, attributes: { customerPrice: pid.split('-')[2] } })); return json({ data: shown.map(r => this.view(r)), included, links: {} }); } if (method === 'POST') { if (rel === 'relationships') { const self = id ? this.res.get(id) : undefined; const items = ((body as { data?: { id: string }[] } | undefined)?.data ?? []); // App Store Connect gives an internal all-builds group every build by itself and refuses adding one by hand // (the exact answer is not recorded; a 409 stands for it) if (self?.type === 'betaGroups' && rel2 === 'builds' && self.attributes.isInternalGroup === true && self.attributes.hasAccessToAllBuilds === true) return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'internal group with access to all builds' }] }, 409); // the link is kept: the item points back at the parent, so GET .../relationships/ and the item's own // relationships list it (a build in a beta group, a tester in a group) if (self) for (const it of items) { const child = this.res.get(it.id); if (!child) continue; const cur = child.rels[type!]; const list = cur === undefined ? [] : Array.isArray(cur) ? cur : [cur]; if (!list.includes(self.id)) child.rels[type!] = [...list, self.id]; } return new Response(null, { status: 204 }); } const out = this.create(body as Parameters[0]); return out instanceof Response ? out : json({ data: this.view(out) }, 201); } if (method === 'PATCH') { if (rel === 'relationships' && type && id) { const data = (body as { data: { id: string } | { id: string }[] | null }).data; if (data && !Array.isArray(data)) { // a to-one relationship (the build of a version): the target points back at the patched resource for (const r of this.children(id, this.res.get(data.id)?.type ?? '')) delete r.rels[singular(type)]; const t = this.res.get(data.id); if (t) t.rels[singular(type)] = id; return new Response(null, { status: 204 }); } // order of a screenshot / preview set const ids = ((data ?? []) as { id: string }[]).map(d => d.id); const base = Math.min(...ids.map(i => this.res.get(i)?.order ?? 0)); ids.forEach((i, k) => { const r = this.res.get(i); if (r) r.order = base + k; }); return new Response(null, { status: 204 }); } let self = id ? this.res.get(id) : undefined; // the age rating declaration is addressed by its app info's id if (self && type && self.type !== type) self = this.children(self.id, type)[0]; if (!self) return json({ errors: [{ status: '404' }] }, 404); const attrs = (body as { data: { attributes?: Record } }).data.attributes ?? {}; for (const [k, v] of Object.entries((body as { data: { relationships?: Record } }).data.relationships ?? {})) { if (v.data) self.rels[k] = v.data.id; else delete self.rels[k]; } // inline creates (subscription prices, introductory offers) become children of the patched resource for (const inc of (body as { included?: { type: string; attributes?: Record; relationships?: Record }[] }).included ?? []) this.put({ type: inc.type, id: `${inc.type.slice(0, 8)}-${this.next}`, attributes: { ...(inc.attributes ?? {}) }, rels: { [singular(self.type)]: self.id, ...Object.fromEntries(Object.entries(inc.relationships ?? {}).map(([k, v]) => [k, v.data.id])) } }); if (attrs.uploaded === true && ASSETS.test(self.type)) { const { uploaded: _u, ...rest } = attrs; Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } }); delete self.attributes.uploadOperations; } else if (self.type === 'reviewSubmissions' && (attrs.submitted === true || attrs.canceled === true)) { // seen live (2026-10-12): a REJECTED item that is not marked resolved blocks every submit with this 409, and an // empty draft cannot be canceled const items = this.children(self.id, 'reviewSubmissionItems'); if (attrs.submitted && items.some(it => it.attributes.state === 'REJECTED')) return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Version is not ready to be submitted yet, please try again later' }] }, 409); if (attrs.canceled && self.attributes.state === 'READY_FOR_REVIEW' && !items.length) return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Resource is not in cancellable state' }] }, 409); // submitting moves the submission (and its items) on; canceling ends it, and (seen live) the versions in a canceled // submission are left DEVELOPER_REJECTED; the app version is editable again (PREPARE_FOR_SUBMISSION) const wasSubmitted = ['WAITING_FOR_REVIEW', 'IN_REVIEW'].includes(String(self.attributes.state)); self.attributes.state = attrs.submitted ? 'WAITING_FOR_REVIEW' : 'CANCELING'; if (attrs.submitted) for (const it of this.children(self.id, 'reviewSubmissionItems')) it.attributes.state = 'READY_FOR_REVIEW'; if (attrs.canceled && wasSubmitted) for (const it of items) for (const [k, vid] of Object.entries(it.rels)) { const v = k === 'reviewSubmission' ? undefined : this.res.get(String(vid)); if (v) v.attributes[v.type === 'appStoreVersions' ? 'appVersionState' : 'state'] = v.type === 'appStoreVersions' ? 'PREPARE_FOR_SUBMISSION' : 'DEVELOPER_REJECTED'; } } else if (self.type === 'reviewSubmissionItems' && attrs.resolved === true) { // marking a REJECTED item resolved puts it back to READY_FOR_REVIEW (seen live) if (self.attributes.state === 'REJECTED') self.attributes.state = 'READY_FOR_REVIEW'; self.attributes.resolved = true; } else Object.assign(self.attributes, attrs); return json({ data: this.view(self) }); } if (method === 'DELETE') { if (!id || !this.res.has(id)) return json({ errors: [{ status: '404' }] }, 404); this.res.delete(id); return new Response(null, { status: 204 }); } return json({ errors: [{ status: '599' }] }, 599); } } // ------------------------------------------------------------------------------------------------------------ Play type PlayState = { details: Record; listings: Record>; images: Record; tracks: Record; bundles: number[] }; const clone = (x: T): T => JSON.parse(JSON.stringify(x)) as T; export class FakePlay { live: PlayState = { details: {}, listings: {}, images: {}, tracks: {}, bundles: [] }; edits = new Map(); committed: string[] = []; products = new Map>(); subs = new Map>(); offers = new Map>(); next = 1; /** every Data safety CSV that was posted */ dataSafety: string[] = []; /** versionCode the next bundle upload reports */ bundleCode = 42; handle(method: string, url: string, body: unknown): Response { const u = new URL(url); const p = u.pathname.replace(/^\/(upload\/)?androidpublisher\/v3\/applications\/[^/]+/, ''); const err = (code: number, status: string, message: string) => json({ error: { code, status, message } }, code); let m: RegExpExecArray | null; if (method === 'POST' && p === '/edits') { const id = `edit-${this.next++}`; this.edits.set(id, clone(this.live)); return json({ id, expiryTimeSeconds: '9999999999' }); } if ((m = /^\/edits\/([^/:]+)(.*)$/.exec(p))) { const [, id, rest] = m; const e = this.edits.get(id!); if (!e) return err(404, 'NOT_FOUND', 'Edit has been deleted'); if (method === 'DELETE' && rest === '') { this.edits.delete(id!); return new Response(null, { status: 204 }); } if (method === 'POST' && rest === ':validate') return json({ id }); if (method === 'POST' && rest === ':commit') { this.live = e; this.edits.delete(id!); this.committed.push(id!); return json({ id }); } if (rest === '/details') { if (method === 'PUT') e.details = body as Record; return json(e.details); } if (rest === '/listings') return json({ listings: Object.values(e.listings) }); if ((m = /^\/listings\/([^/]+)$/.exec(rest!))) { if (method === 'PUT') e.listings[m[1]!] = body as Record; return json(e.listings[m[1]!] ?? {}); } if ((m = /^\/listings\/([^/]+)\/(\w+)$/.exec(rest!))) { const k = `${m[1]}/${m[2]}`; if (method === 'DELETE') { e.images[k] = []; return json({ deleted: [] }); } if (method === 'POST') { const h = new Bun.CryptoHasher('sha256').update(body as Uint8Array).digest('hex'); (e.images[k] ??= []).push(h); return json({ image: { sha256: h } }); } return json({ images: (e.images[k] ?? []).map(sha256 => ({ sha256 })) }); } if (rest === '/tracks') return json({ tracks: Object.entries(e.tracks).map(([track, t]) => ({ track, ...t })) }); if ((m = /^\/tracks\/(\w+)$/.exec(rest!))) { if (method === 'PUT') e.tracks[m[1]!] = { releases: (body as { releases: unknown[] }).releases }; return json({ track: m[1], releases: e.tracks[m[1]!]?.releases ?? [] }); } if (rest === '/bundles') { if (method === 'POST') { const code = this.bundleCode; e.bundles.push(code); return json({ versionCode: code }); } return json({ bundles: e.bundles.map(versionCode => ({ versionCode })) }); } if (rest === '/apks') return json({ apks: [] }); return err(599, 'UNKNOWN', `fake play: ${method} ${p}`); } if (method === 'POST' && p === '/pricing:convertRegionPrices') { const usd = body as { price: { units?: string; nanos?: number } }; const price = { currencyCode: 'USD', units: usd.price.units ?? '0', nanos: usd.price.nanos ?? 0 }; return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } } }, convertedOtherRegionsPrice: { usdPrice: price, eurPrice: { ...price, currencyCode: 'EUR' } }, regionVersion: { version: '2022/02' } }); } if (method === 'GET' && p === '/oneTimeProducts') return json({ oneTimeProducts: [...this.products.values()] }); if ((m = /^\/(?:oneTimeProducts|onetimeproducts)\/([^/:]+)$/.exec(p))) { const id = decodeURIComponent(m[1]!); if (method === 'PATCH') { const cur = this.products.get(id); if (!cur && u.searchParams.get('allowMissing') !== 'true') return err(404, 'NOT_FOUND', 'no product'); const b = clone(body as Record); const mask = (u.searchParams.get('updateMask') ?? '').split(','); const next = cur ? { ...cur, ...(mask.includes('listings') ? { listings: b.listings } : {}), ...(mask.includes('purchaseOptions') ? { purchaseOptions: b.purchaseOptions } : {}) } : b; for (const o of (next.purchaseOptions as { state?: string; purchaseOptionId: string }[] | undefined) ?? []) { const was = (cur?.purchaseOptions as { state?: string; purchaseOptionId: string }[] | undefined)?.find(x => x.purchaseOptionId === o.purchaseOptionId)?.state; o.state = was ?? 'DRAFT'; } this.products.set(id, next); return json(next); } const cur = this.products.get(id); return cur ? json(cur) : err(404, 'NOT_FOUND', 'no product'); } if ((m = /^\/oneTimeProducts\/([^/]+)\/purchaseOptions:batchUpdateStates$/.exec(p))) { const cur = this.products.get(decodeURIComponent(m[1]!)); for (const o of (cur?.purchaseOptions as { state?: string }[] | undefined) ?? []) o.state = 'ACTIVE'; return json({}); } if (method === 'GET' && p === '/subscriptions') return json({ subscriptions: [...this.subs.values()] }); if (method === 'POST' && p === '/subscriptions') { const id = u.searchParams.get('productId')!; if (this.subs.has(id)) return err(409, 'ALREADY_EXISTS', `Subscription ${id} already exists`); const b = clone(body as Record); for (const bp of (b.basePlans as { state?: string }[] | undefined) ?? []) bp.state = 'DRAFT'; this.subs.set(id, b); return json(b); } if ((m = /^\/subscriptions\/([^/:]+)$/.exec(p))) { const id = decodeURIComponent(m[1]!); const cur = this.subs.get(id); if (!cur) return err(404, 'NOT_FOUND', 'no subscription'); if (method === 'PATCH') { const b = clone(body as Record); const states = new Map(((cur.basePlans as { basePlanId: string; state?: string }[] | undefined) ?? []).map(x => [x.basePlanId, x.state])); for (const bp of (b.basePlans as { basePlanId: string; state?: string }[] | undefined) ?? []) bp.state = states.get(bp.basePlanId) ?? 'DRAFT'; this.subs.set(id, { ...cur, ...b }); } return json(this.subs.get(id)); } if ((m = /^\/subscriptions\/([^/]+)\/basePlans\/([^/:]+):activate$/.exec(p))) { const cur = this.subs.get(decodeURIComponent(m[1]!)); for (const bp of (cur?.basePlans as { basePlanId: string; state?: string }[] | undefined) ?? []) if (bp.basePlanId === m[2]) bp.state = 'ACTIVE'; return json(cur ?? {}); } if ((m = /^\/subscriptions\/([^/]+)\/basePlans\/([^/]+)\/offers$/.exec(p))) { const key = `${decodeURIComponent(m[1]!)}/${m[2]}`; if (method === 'GET') return json({ subscriptionOffers: [...this.offers.entries()].filter(([k]) => k.startsWith(key + '/')).map(([, v]) => v) }); const oid = u.searchParams.get('offerId')!; if (this.offers.has(`${key}/${oid}`)) return err(409, 'ALREADY_EXISTS', `Offer ${oid} already exists`); const b = { ...clone(body as Record), state: 'DRAFT' }; this.offers.set(`${key}/${oid}`, b); return json(b); } if ((m = /^\/subscriptions\/([^/]+)\/basePlans\/([^/]+)\/offers\/([^/:]+)(:activate)?$/.exec(p))) { const o = this.offers.get(`${decodeURIComponent(m[1]!)}/${m[2]}/${decodeURIComponent(m[3]!)}`); if (!o) return err(404, 'NOT_FOUND', 'no offer'); if (m[4]) o.state = 'ACTIVE'; return json(o); } if (method === 'POST' && p === '/dataSafety') { this.dataSafety.push(String((body as { safetyLabels?: string }).safetyLabels ?? '')); return json({}); } return err(599, 'UNKNOWN', `fake play: ${method} ${p}`); } } // ------------------------------------------------------------------------------------------------- fetch + counting export interface Stores { asc: FakeAsc; play: FakePlay; calls: Call[]; faults: Fault[]; failPart?: (n: number) => boolean } const realFetch = globalThis.fetch; /** Installs both fakes as fetch. Faults apply in order (`times`, default 1). */ export function installStores(): Stores { const s: Stores = { asc: new FakeAsc(), play: new FakePlay(), calls: [], faults: [] }; let partNo = 0; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input instanceof Request ? input.url : input); const method = (init?.method ?? 'GET').toUpperCase(); const raw = init?.body; let body: unknown = raw; if (typeof raw === 'string') { try { body = JSON.parse(raw); } catch { body = raw; } } if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 }); const path = url.replace(/^https:\/\/[^/]+/, ''); s.calls.push({ method, url, path, body }); const f = s.faults.find(x => (x.times ?? 1) > 0 && x.method === method && x.match.test(decodeURIComponent(path))); if (f) f.times = (f.times ?? 1) - 1; if (f?.when === 'before') return json({ errors: [{ status: String(f.status ?? 503), ...(f.code ? { code: f.code } : {}), detail: f.detail ?? 'injected' }] }, f.status ?? 503); let res: Response; if (url.startsWith(UPLOAD_HOST)) { partNo++; if (s.failPart?.(partNo)) return new Response('injected part failure', { status: 400 }); s.asc.parts.push(url.slice(UPLOAD_HOST.length)); res = new Response(null, { status: 200 }); } else if (url.startsWith(ASC)) res = s.asc.handle(method, path, body); else if (url.startsWith('https://androidpublisher.googleapis.com/')) res = s.play.handle(method, url, body instanceof Uint8Array ? body : body); else res = new Response(`fake: no route ${method} ${url}`, { status: 599 }); if (f?.when === 'after') return json({ errors: [{ status: String(f.status ?? 502), detail: 'injected after the write' }] }, f.status ?? 502); return res; }) as typeof fetch; return s; } export function uninstallStores(): void { globalThis.fetch = realFetch; } /** Store writes in a call log: every non-GET request, except Play's edit bookkeeping (opening and deleting a * throwaway edit, validating it) and the price conversion, which change nothing on the store. */ export function writes(calls: Call[]): Call[] { return calls.filter(c => c.method !== 'GET' && !(c.method === 'POST' && /\/edits$/.test(c.path)) && !(c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.path)) && !/:validate$|pricing:convertRegionPrices$/.test(c.path)); }