storebound/tests/helpers/fake-stores.ts

476 lines
33 KiB
TypeScript

// 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';
export type R = { type: string; id: string; attributes: Record<string, unknown>; rels: Record<string, string | string[]>; 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<string, { type: string; one?: boolean }> = {
'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' },
};
/** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */
const UNIQUE: Record<string, (r: R) => 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<string, Record<string, unknown>> = {
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<string, R>();
next = 1;
/** new resources of these types stay out of list reads for that many reads */
lag: Record<string, number> = {};
/** 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, 'order'>): 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<string, unknown> {
const relationships: Record<string, unknown> = {};
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<string, unknown>; relationships?: Record<string, { data: unknown }> }; included?: { type: string; id: string; attributes?: Record<string, unknown>; relationships?: Record<string, { data: unknown }> }[] }): R | Response {
const temp = new Map<string, string>();
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<string, { data: unknown }>): Record<string, string | string[]> => 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<R, 'order'> = { 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;
}
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') {
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-<territory>-<price>)
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') { return new Response(null, { status: 204 }); }
const out = this.create(body as Parameters<FakeAsc['create']>[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<string, unknown> } }).data.attributes ?? {};
for (const [k, v] of Object.entries((body as { data: { relationships?: Record<string, { data: { id: string } | null }> } }).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<string, unknown>; relationships?: Record<string, { data: { id: string } }> }[] }).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<string, unknown>; listings: Record<string, Record<string, unknown>>; images: Record<string, string[]>; tracks: Record<string, { releases: unknown[] }>; bundles: number[] };
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x)) as T;
export class FakePlay {
live: PlayState = { details: {}, listings: {}, images: {}, tracks: {}, bundles: [] };
edits = new Map<string, PlayState>();
committed: string[] = [];
products = new Map<string, Record<string, unknown>>();
subs = new Map<string, Record<string, unknown>>();
offers = new Map<string, Record<string, unknown>>();
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<string, unknown>; 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<string, unknown>; 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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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<string, unknown>), 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));
}