668 lines
48 KiB
TypeScript
668 lines
48 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/betaAppLocalizations': { type: 'betaAppLocalizations' },
|
|
'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' },
|
|
'inAppPurchaseOfferCodes/prices': { type: 'inAppPurchaseOfferPrices' },
|
|
'inAppPurchasePriceSchedules/manualPrices': { type: 'inAppPurchasePrices' },
|
|
'inAppPurchasePriceSchedules/baseTerritory': { type: 'territories', one: true },
|
|
'appPriceSchedules/manualPrices': { type: 'appPrices' },
|
|
'inAppPurchaseAvailabilities/availableTerritories': { type: 'territories' },
|
|
'winBackOffers/prices': { type: 'winBackOfferPrices' },
|
|
'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' },
|
|
'betaGroups/builds': { type: 'builds' },
|
|
'builds/buildBetaDetail': { type: 'buildBetaDetails', one: true },
|
|
'builds/betaAppReviewSubmission': { type: 'betaAppReviewSubmissions', one: true },
|
|
'buildBundles/buildBundleFileSizes': { type: 'buildBundleFileSizes' },
|
|
'apps/analyticsReportRequests': { type: 'analyticsReportRequests' },
|
|
'apps/assetLibrary': { type: 'appAssetLibraries', one: true },
|
|
'appAssetLibraries/images': { type: 'appAssetLibraryImages' },
|
|
'appAssetLibraries/videos': { type: 'appAssetLibraryVideos' },
|
|
'appStoreVersionLocalizations/placements': { type: 'appAssetLibraryPlacements' },
|
|
'apps/gameCenterDetail': { type: 'gameCenterDetails', one: true },
|
|
'appStoreVersions/gameCenterAppVersion': { type: 'gameCenterAppVersions', one: true },
|
|
'gameCenterDetails/gameCenterLeaderboards': { type: 'gameCenterLeaderboards' },
|
|
'gameCenterDetails/gameCenterAchievements': { type: 'gameCenterAchievements' },
|
|
'gameCenterLeaderboards/localizations': { type: 'gameCenterLeaderboardLocalizations' },
|
|
'gameCenterAchievements/localizations': { type: 'gameCenterAchievementLocalizations' },
|
|
};
|
|
/** 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(),
|
|
gameCenterDetails: r => String(r.rels.app),
|
|
gameCenterAppVersions: r => String(r.rels.appStoreVersion),
|
|
gameCenterLeaderboards: r => String(r.attributes.vendorIdentifier),
|
|
gameCenterAchievements: r => String(r.attributes.vendorIdentifier),
|
|
gameCenterLeaderboardLocalizations: r => `${r.rels.gameCenterLeaderboard}|${r.attributes.locale}`,
|
|
gameCenterAchievementLocalizations: r => `${r.rels.gameCenterAchievement}|${r.attributes.locale}`,
|
|
};
|
|
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$|^appAssetLibrary(Images|Videos)$/;
|
|
/** App Asset Library assets: a state of their own (AWAITING_UPLOAD -> PREPARE_FOR_SUBMISSION after the commit, as Apple's
|
|
* "App Store asset statuses" describe it: processed, not in a submission yet; a test sets the review states). */
|
|
const LIBRARY = /^appAssetLibrary(Images|Videos)$/;
|
|
/** 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[] = [];
|
|
/** The attached builds carry com.apple.developer.game-center: submitting answers Apple's 409 (seen live) until the
|
|
* app has a gameCenterDetail and the version a gameCenterAppVersion. */
|
|
gameCenterBuild = false;
|
|
/** localization ids whose PATCH answers 409 UNMODIFIABLE besides those in state APPROVED / ACTIVE */
|
|
unmodifiable = new Set<string>();
|
|
/** ids whose PATCH was refused with that 409 */
|
|
patchRefused: 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 };
|
|
}
|
|
|
|
/** Whether a group of the tester has a build its testers can install (external: BETA_APPROVED / IN_BETA_TESTING; internal: READY_FOR_BETA_TESTING / IN_BETA_TESTING). */
|
|
hasInstallableBuild(testerId: string): boolean {
|
|
const gids = [this.res.get(testerId)?.rels.betaGroups ?? []].flat();
|
|
return gids.some(gid => {
|
|
const g = this.res.get(gid);
|
|
if (!g) return false;
|
|
const internal = g.attributes.isInternalGroup === true;
|
|
const builds = internal && g.attributes.hasAccessToAllBuilds === true ? this.all('builds') : this.children(gid, 'builds');
|
|
return builds.some(b => {
|
|
const st = String(this.children(b.id, 'buildBetaDetails')[0]?.attributes[internal ? 'internalBuildState' : 'externalBuildState'] ?? '');
|
|
return internal ? ['READY_FOR_BETA_TESTING', 'IN_BETA_TESTING'].includes(st) : ['BETA_APPROVED', 'IN_BETA_TESTING'].includes(st);
|
|
});
|
|
});
|
|
}
|
|
|
|
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';
|
|
}
|
|
// seen live: inviting a tester whose groups have no installable build (an external build is installable after Beta App
|
|
// Review) is 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS
|
|
if (d.type === 'betaTesterInvitations' && !this.hasInstallableBuild(String(main.rels.betaTester)))
|
|
return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', title: 'There are no installable builds',
|
|
detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' }] }, 409);
|
|
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' }] }];
|
|
if (LIBRARY.test(d.type)) main.attributes.state = 'AWAITING_UPLOAD'; else 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);
|
|
// include=<relationship>: the children of that type (a build's buildBundles)
|
|
if (!rel) return self && self.type === type ? json({ data: this.view(self), included: (u.searchParams.get('include') ?? '').split(',').filter(Boolean).flatMap(t => this.children(self.id, t).map(r => this.view(r))) }) : json({ errors: [{ status: '404' }] }, 404);
|
|
if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) });
|
|
// an availability's territories are the ids its create named (a to-many relationship, not child resources)
|
|
if (rel === 'availableTerritories' && self) {
|
|
const ids = self.rels.availableTerritories;
|
|
return json({ data: (Array.isArray(ids) ? ids : ids ? [ids] : []).map(t => ({ type: 'territories', id: t })), links: {} });
|
|
}
|
|
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);
|
|
// seen live (2026-10-23): a live (approved) localization cannot be edited
|
|
if (/^(inAppPurchase|subscription|subscriptionGroup)Localizations$/.test(self.type) && (['APPROVED', 'ACTIVE'].includes(String(self.attributes.state)) || this.unmodifiable.has(self.id))) {
|
|
this.patchRefused.push(self.id);
|
|
return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE', title: 'The provided entity includes an attribute with an invalid value',
|
|
detail: `Cannot edit ${self.type.replace(/s$/, '').replace(/^./, c => c.toUpperCase())} when it is in ACTIVE state` }] }, 409);
|
|
}
|
|
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;
|
|
if (LIBRARY.test(self.type)) Object.assign(self.attributes, rest, { state: this.parts.includes(self.id) ? 'PREPARE_FOR_SUBMISSION' : 'FAILED' });
|
|
else Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
|
|
delete self.attributes.uploadOperations;
|
|
} else if (LIBRARY.test(self.type) && typeof attrs.archived === 'boolean') {
|
|
// archive / unarchive (model: an unarchived asset is back in Prepare for Submission)
|
|
const { archived, ...rest } = attrs;
|
|
Object.assign(self.attributes, rest, { state: archived ? 'ARCHIVED' : 'PREPARE_FOR_SUBMISSION' });
|
|
delete self.attributes.archived;
|
|
} 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 && this.gameCenterBuild) {
|
|
const app = String(self.rels.app);
|
|
const ver = items.map(it => String(it.rels.appStoreVersion ?? '')).find(Boolean);
|
|
if (!this.children(app, 'gameCenterDetails').length || (ver && !this.children(ver, 'gameCenterAppVersions').length))
|
|
return json({ errors: [{ status: '409', code: 'STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED', title: 'The request cannot be fulfilled because of the state of another resource.',
|
|
detail: 'The build for this app store version has com.apple.developer.game-center entitlement but appStoreVersion is missing gameCenterConfigurations relationship.' }] }, 409);
|
|
}
|
|
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;
|
|
/** How many validate / commit calls without changesNotSentForReview=true Play refuses with its 400 "Changes cannot
|
|
* be sent for review automatically" (after a rejection, pending changes, managed publishing: pitfall 72). */
|
|
refuseAutoReview = 0;
|
|
/** convertRegionPrices also answers KR (KRW, 1450 per USD) */
|
|
convertKr = false;
|
|
/** per region the min / max Play accepts in a product's regional price (live: KR 70-600000 KRW); a PATCH outside answers 400 */
|
|
priceLimits: Record<string, { min: number; max: number; symbol: string }> = {};
|
|
/** product id -> 400 message for its PATCH */
|
|
rejectProducts: Record<string, string> = {};
|
|
/** live Play: committing a language that is new in the edit needs its hi-res icon, feature graphic and 2+ phone screenshots
|
|
* in the same edit (403 PERMISSION_DENIED), 2+ tablet sets too when `requireTablets` */
|
|
requireNewLangAssets = false;
|
|
requireTablets = false;
|
|
/** validate calls that carried changesNotSentForReview (live Play answers them 400 "Unknown name") */
|
|
validateWithNotSent = 0;
|
|
|
|
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 }); }
|
|
// live: only edits.commit binds changesNotSentForReview; validate refuses the parameter
|
|
if (method === 'POST' && rest === ':validate' && u.searchParams.has('changesNotSentForReview')) {
|
|
this.validateWithNotSent++;
|
|
return json({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Invalid JSON payload received. Unknown name "changesNotSentForReview": Cannot bind query parameter. Field \'changesNotSentForReview\' could not be found in request message.' } }, 400);
|
|
}
|
|
if (method === 'POST' && (rest === ':validate' || rest === ':commit') && this.refuseAutoReview > 0 && u.searchParams.get('changesNotSentForReview') !== 'true') {
|
|
this.refuseAutoReview--;
|
|
return json({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. Once committed, the changes in this edit can be sent for review from the Google Play Console UI.', errors: [{ message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true.', domain: 'global', reason: 'badRequest' }] } }, 400);
|
|
}
|
|
if (method === 'POST' && rest === ':validate') return json({ id });
|
|
if (method === 'POST' && rest === ':commit' && this.requireNewLangAssets) {
|
|
for (const lang of Object.keys(e.listings).filter(l => !this.live.listings[l])) {
|
|
const n = (t: string) => (e.images[`${lang}/${t}`] ?? []).length;
|
|
const miss = n('icon') < 1 ? `This app has no high resolution icon set for language ${lang}` : n('featureGraphic') < 1 ? `This app has no feature graphic set for language ${lang}`
|
|
: n('phoneScreenshots') < 2 || (this.requireTablets && (n('sevenInchScreenshots') < 2 || n('tenInchScreenshots') < 2)) ? `This app has too few screenshots for language ${lang}` : '';
|
|
if (miss) return err(403, 'PERMISSION_DENIED', miss);
|
|
}
|
|
}
|
|
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 (this.requireNewLangAssets && method === 'GET' && !e.listings[m[1]!]) return err(404, 'NOT_FOUND', `Listing for language '${m[1]}' not found.`);
|
|
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 };
|
|
const kr = this.convertKr ? { KR: { regionCode: 'KR', price: { currencyCode: 'KRW', units: String(Math.round((Number(price.units) + price.nanos / 1e9) * 1450)), nanos: 0 } } } : {};
|
|
return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } }, ...kr },
|
|
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');
|
|
if (this.rejectProducts[id]) return err(400, 'INVALID_ARGUMENT', this.rejectProducts[id]!);
|
|
const b = clone(body as Record<string, unknown>);
|
|
for (const o of (b.purchaseOptions as { regionalPricingAndAvailabilityConfigs?: { regionCode: string; price: { units?: string; nanos?: number } }[] }[] | undefined) ?? [])
|
|
for (const r of o.regionalPricingAndAvailabilityConfigs ?? []) {
|
|
const l = this.priceLimits[r.regionCode];
|
|
const v = Number(r.price.units ?? 0) + (r.price.nanos ?? 0) / 1e9;
|
|
if (l && (v < l.min || v > l.max)) return err(400, 'INVALID_ARGUMENT', `Price for ${r.regionCode} must be between ${l.symbol}${l.min.toLocaleString('en-US')} and ${l.symbol}${l.max.toLocaleString('en-US')}, found ${l.symbol}${v.toLocaleString('en-US')}`);
|
|
}
|
|
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
|
|
|
|
// ----------------------------------------------------------------- Play Games Services Configuration API (v1configuration)
|
|
|
|
/** achievementConfigurations / leaderboardConfigurations: list (items, nextPageToken), insert (Google generates the
|
|
* id, CgkI...), update (PUT replaces the configuration; `published` stays Google's). Shapes from Google's reference. */
|
|
export class FakeGames {
|
|
achievements = new Map<string, Record<string, unknown>>();
|
|
leaderboards = new Map<string, Record<string, unknown>>();
|
|
next = 1;
|
|
handle(method: string, url: string, body: unknown): Response {
|
|
const p = new URL(url).pathname.replace(/^\/games\/v1configuration/, '');
|
|
let m: RegExpExecArray | null;
|
|
if ((m = /^\/applications\/(\d+)\/(achievements|leaderboards)$/.exec(p))) {
|
|
const map = this[m[2] as 'achievements' | 'leaderboards'];
|
|
if (method === 'GET') return json({ kind: `gamesConfiguration#${m[2] === 'achievements' ? 'achievement' : 'leaderboard'}ConfigurationListResponse`, items: [...map.values()] });
|
|
const id = `CgkI${m[1]}EAIQ${this.next++}`;
|
|
const r = { ...(body as Record<string, unknown>), id, token: id };
|
|
map.set(id, r);
|
|
return json(r);
|
|
}
|
|
if ((m = /^\/(achievements|leaderboards)\/([^/]+)$/.exec(p))) {
|
|
const map = this[m[1] as 'achievements' | 'leaderboards'];
|
|
const id = decodeURIComponent(m[2]!);
|
|
if (!map.has(id)) return json({ error: { code: 404, status: 'NOT_FOUND', message: 'Not found' } }, 404);
|
|
if (method === 'PUT') map.set(id, { ...(body as Record<string, unknown>), id, token: id, published: map.get(id)!.published });
|
|
return json(map.get(id));
|
|
}
|
|
return json({ error: { code: 599, message: `fake games: ${method} ${p}` } }, 599);
|
|
}
|
|
}
|
|
|
|
export interface Stores { asc: FakeAsc; play: FakePlay; games: FakeGames; 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(), games: new FakeGames(), 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 if (url.startsWith('https://gamesconfiguration.googleapis.com/')) res = s.games.handle(method, url, 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));
|
|
}
|