513 lines
35 KiB
TypeScript
513 lines
35 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';
|
|
import { APPLE_LIVE_REJECTED, resolveOp } from '../../cli/api/client';
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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-<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') {
|
|
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/<rel> 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<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));
|
|
}
|