817 lines
56 KiB
TypeScript
817 lines
56 KiB
TypeScript
// Idempotency of every store write (docs/idempotency.md): each write command runs twice against stateful doubles of
|
|
// App Store Connect and Google Play (tests/helpers/fake-stores.ts) and the second run must not write anything. Plus
|
|
// the failure cases: a crash in the middle of a screenshot set, a create whose answer is lost (5xx after the store
|
|
// applied it), a create the store refuses as a duplicate (409), the list lag right after a create, Play edits on
|
|
// error paths and an unclear commit, the lock between two runs, and the retry rules of the API clients.
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { hostname } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import type { ShipConfig } from '../cli/lib/config';
|
|
import { parseArgs } from '../cli/lib/args';
|
|
import { acquireLock, lockKeys, staleLock } from '../cli/lib/lock';
|
|
import { AmbiguousWriteError, createOnce, findOrCreate, isConflict, readJournal, retrySafe, upsertTiming } from '../cli/lib/upsert';
|
|
import { keptPrefix } from '../cli/apple/asc';
|
|
import { sameRelease } from '../cli/play/release';
|
|
import { sameReleases } from '../cli/play/rollout';
|
|
import { playClient } from '../cli/play/client';
|
|
import { run as appleIap } from '../cli/apple/iap';
|
|
import { run as appleScreenshots } from '../cli/apple/screenshots';
|
|
import { run as appleSubscriptions } from '../cli/apple/subscriptions';
|
|
import { run as appleCpp } from '../cli/apple/cpp';
|
|
import { run as appleEvents } from '../cli/apple/events';
|
|
import { run as appleTestflight } from '../cli/apple/testflight';
|
|
import { run as appleReviewDetails } from '../cli/apple/review-details';
|
|
import { run as appleBundle } from '../cli/apple/bundle';
|
|
import { run as appleOfferCodes } from '../cli/apple/offer-codes';
|
|
import { run as applePpo } from '../cli/apple/ppo';
|
|
import { run as appleIapSubmit } from '../cli/apple/iap-submit';
|
|
import { run as appleRelease } from '../cli/apple/release';
|
|
import { run as appleEncryption } from '../cli/apple/encryption';
|
|
import { run as appleAdhoc } from '../cli/apple/adhoc';
|
|
import { run as playProducts } from '../cli/play/products';
|
|
import { run as playSubscriptions } from '../cli/play/subscriptions';
|
|
import { run as playImages } from '../cli/play/images';
|
|
import { run as playRollout } from '../cli/play/rollout';
|
|
import { run as playRelease } from '../cli/play/release';
|
|
import { run as appleListing } from '../cli/apple/listing';
|
|
import { run as appleAppinfo } from '../cli/apple/appinfo';
|
|
import { run as applePromoted } from '../cli/apple/promoted';
|
|
import { run as appleWinBack } from '../cli/apple/win-back';
|
|
import { run as applePreorder } from '../cli/apple/preorder';
|
|
import { run as playListing } from '../cli/play/listing';
|
|
import { run as playDataSafety } from '../cli/play/data-safety';
|
|
import { run as playUpload } from '../cli/play/upload';
|
|
import { run as prices } from '../cli/prices';
|
|
import { makeAab } from './build-fixtures';
|
|
import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock';
|
|
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
|
|
|
|
let s: Stores;
|
|
beforeAll(() => { ascEnv(); playEnv(); });
|
|
afterAll(restore);
|
|
beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); });
|
|
afterEach(() => { uninstallStores(); });
|
|
|
|
const APP = '6400000101';
|
|
const LISTING = '# Play\n\n## English\n<!-- play: en-US -->\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n';
|
|
const png = (n: number): Uint8Array => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, n, n + 1, n + 2]);
|
|
|
|
const CONFIG: ShipConfig = {
|
|
...BASE,
|
|
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }],
|
|
products: [
|
|
{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'Pro', description: 'Every feature.' }, de: { title: 'Pro', description: 'Alles.' } } },
|
|
{ id: 'com.example.notes.tip', type: 'consumable', usd: 0.99, listings: { en: { title: 'Tip', description: 'Say thanks.' } },
|
|
offerCodes: [{ name: 'Launch', free: true, eligibility: ['NON_SPENDER'] } as never] },
|
|
],
|
|
apple: {
|
|
...BASE.apple!, testflight: { groups: [{ name: 'Friends' }, { name: 'Public', internal: false } as never] } as never,
|
|
customProductPages: [{ name: 'Spring', promotionalText: { en: 'Spring sale' } } as never],
|
|
events: [{ referenceName: 'Spring event', badge: 'SPECIAL_EVENT', listings: { en: { name: 'Spring', shortDescription: 'Short', longDescription: 'Long text' } },
|
|
schedule: { publishStart: '2030-03-01T00:00:00Z', eventStart: '2030-03-02T00:00:00Z', eventEnd: '2030-03-09T00:00:00Z' } } as never],
|
|
},
|
|
};
|
|
|
|
/** Runs a command twice; returns the writes of each run. The second must be empty. */
|
|
async function twice(run: () => Promise<void>): Promise<{ first: string[]; second: string[] }> {
|
|
const a = s.calls.length;
|
|
const r1 = await capture(run);
|
|
if (r1.exit) throw new Error(`first run exit ${r1.exit}:\n${r1.out}\n${r1.err}`);
|
|
const b = s.calls.length;
|
|
const r2 = await capture(run);
|
|
if (r2.exit) throw new Error(`second run exit ${r2.exit}:\n${r2.out}\n${r2.err}`);
|
|
const show = (from: number, to?: number) => writes(s.calls.slice(from, to)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '')}`);
|
|
return { first: show(a, b), second: show(b) };
|
|
}
|
|
|
|
const count = (type: string): number => s.asc.all(type).length;
|
|
|
|
describe('apple testflight groups: bare lists, sync creates', () => {
|
|
test('bare `groups` writes nothing and names what sync would create', async () => {
|
|
const p = project(CONFIG);
|
|
const before = s.calls.length;
|
|
const r = await capture(() => appleTestflight(p.ctx(['groups'])));
|
|
expect(writes(s.calls.slice(before))).toEqual([]);
|
|
expect(r.out).toContain('would create "Friends", "Public"');
|
|
expect(r.out).toContain('storebound apple testflight groups sync');
|
|
});
|
|
});
|
|
|
|
describe('apple testflight distribute (real use 2026-10-13)', () => {
|
|
const TF: ShipConfig = { ...CONFIG, apple: { ...CONFIG.apple!, testflight: { groups: [{ name: 'Team', internal: true, allBuilds: true }, { name: 'Friends' }, { name: 'Devs', internal: true }] } as never } };
|
|
const groupsAndBuild = () => {
|
|
s.asc.put({ type: 'betaGroups', id: 'grp-team', attributes: { name: 'Team', isInternalGroup: true, hasAccessToAllBuilds: true }, rels: { app: APP } });
|
|
s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } });
|
|
s.asc.put({ type: 'betaGroups', id: 'grp-devs', attributes: { name: 'Devs', isInternalGroup: true, hasAccessToAllBuilds: false }, rels: { app: APP } });
|
|
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '1', processingState: 'VALID', usesNonExemptEncryption: false, expired: false }, rels: { app: APP } });
|
|
};
|
|
|
|
test('live shape: GET /v1/betaGroups?filter[builds]= is a 400 (the old lookup fails here as it did live)', async () => {
|
|
const r = s.asc.handle('GET', `/v1/betaGroups?filter[app]=${APP}&filter[builds]=b-1&limit=200`, undefined);
|
|
expect(r.status).toBe(400);
|
|
expect(JSON.stringify(await r.json())).toContain('PARAMETER_ERROR.ILLEGAL');
|
|
});
|
|
|
|
test('an internal all-builds group: reported as already available, nothing added', async () => {
|
|
groupsAndBuild();
|
|
const p = project(TF);
|
|
const r = await twice(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team', '--yes'])));
|
|
expect(r.first).toEqual([]);
|
|
expect(r.second).toEqual([]);
|
|
const out = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team', '--yes'])));
|
|
expect(out.out).toContain('already available to Team (internal, all builds)');
|
|
expect(s.calls.some(c => /filter\[builds\]/.test(decodeURIComponent(c.path)))).toBe(false);
|
|
});
|
|
|
|
test('an internal group without all builds and an external group: added once, the re-run adds nothing', async () => {
|
|
groupsAndBuild();
|
|
const p = project(TF);
|
|
const r = await twice(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team,Devs,Friends', '--yes'])));
|
|
expect(r.first).toEqual(['POST /v1/betaGroups/grp-devs/relationships/builds', 'POST /v1/betaGroups/grp-friends/relationships/builds']);
|
|
expect(r.second).toEqual([]);
|
|
const out = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Devs', '--yes'])));
|
|
expect(out.out).toContain('already in Devs (internal)');
|
|
});
|
|
|
|
test('a group in the config but not on App Store Connect is created first (groups sync), then gets the build; the re-run writes nothing', async () => {
|
|
const p = project(TF);
|
|
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '1', processingState: 'VALID', usesNonExemptEncryption: false, expired: false }, rels: { app: APP } });
|
|
s.asc.put({ type: 'betaGroups', id: 'grp-devs', attributes: { name: 'Devs', isInternalGroup: true, hasAccessToAllBuilds: false }, rels: { app: APP } });
|
|
const dry = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Friends', '--dry'])));
|
|
expect(dry.out).toContain('would create beta group(s) from apple.testflight.groups first: "Friends"');
|
|
expect(count('betaGroups')).toBe(1);
|
|
const r = await twice(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Friends,Devs', '--yes'])));
|
|
expect(r.first[0]).toBe('POST /v1/betaGroups');
|
|
expect(r.first.filter(w => w === 'POST /v1/betaGroups')).toHaveLength(1);
|
|
expect(r.first.filter(w => /relationships\/builds$/.test(w))).toHaveLength(2);
|
|
expect(r.second).toEqual([]);
|
|
// only the missing one of --group was created; Team (configured, not asked for) was not; nothing removed
|
|
expect(s.asc.all('betaGroups').map(g => g.attributes.name).sort()).toEqual(['Devs', 'Friends']);
|
|
expect(s.calls.some(c => c.method === 'DELETE')).toBe(false);
|
|
});
|
|
|
|
test('an unknown group (not in the config) still lists what exists', async () => {
|
|
const p = project(TF);
|
|
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '1', processingState: 'VALID' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } });
|
|
await expect(capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Nope', '--yes']))))
|
|
.rejects.toThrow('unknown group "Nope": App Store Connect has "Friends"');
|
|
});
|
|
});
|
|
|
|
describe('App Store Connect: a second run writes nothing', () => {
|
|
test('apple iap: product, localizations, price schedule, availability once', async () => {
|
|
const p = project(CONFIG);
|
|
const r = await twice(() => appleIap(p.ctx([])));
|
|
expect(r.first).toContain('POST /v2/inAppPurchases');
|
|
expect(r.second).toEqual([]);
|
|
expect(count('inAppPurchases')).toBe(2);
|
|
expect(count('inAppPurchaseLocalizations')).toBe(3);
|
|
expect(count('inAppPurchasePriceSchedules')).toBe(2);
|
|
expect(count('inAppPurchaseAvailabilities')).toBe(2);
|
|
});
|
|
|
|
test('apple screenshots: sets and screenshots once, in order', async () => {
|
|
const files: Record<string, Uint8Array> = {};
|
|
for (const l of ['en', 'de']) for (const d of ['ios69', 'ios65']) for (const n of [1, 2, 3]) files[`store/renders/${l}/${d}/0${n}.png`] = png(n + (d === 'ios65' ? 10 : 0) + (l === 'de' ? 20 : 0));
|
|
const p = project(CONFIG, files);
|
|
const v = s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
|
for (const loc of ['en-US', 'de-DE']) s.asc.put({ type: 'appStoreVersionLocalizations', id: `loc-${loc}`, attributes: { locale: loc }, rels: { appStoreVersion: v.id } });
|
|
const r = await twice(() => appleScreenshots(p.ctx([])));
|
|
expect(r.first.filter(x => x === 'POST /v1/appScreenshotSets')).toHaveLength(4);
|
|
expect(r.second).toEqual([]);
|
|
expect(count('appScreenshotSets')).toBe(4);
|
|
expect(count('appScreenshots')).toBe(12);
|
|
});
|
|
|
|
test('apple screenshots --platform macos: the Mac set (APP_DESKTOP) on the macOS version only', async () => {
|
|
const files: Record<string, Uint8Array> = {};
|
|
for (const l of ['en', 'de']) for (const n of [1, 2]) files[`store/renders/${l}/mac/0${n}.png`] = png(n + (l === 'de' ? 40 : 30));
|
|
const p = project({ ...CONFIG, apple: { ...CONFIG.apple!, mac: true } }, files);
|
|
const ios = s.asc.put({ type: 'appStoreVersions', id: 'ver-ios', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
|
const mac = s.asc.put({ type: 'appStoreVersions', id: 'ver-mac', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, rels: { app: APP } });
|
|
for (const v of [ios, mac]) for (const loc of ['en-US', 'de-DE']) s.asc.put({ type: 'appStoreVersionLocalizations', id: `loc-${v.id}-${loc}`, attributes: { locale: loc }, rels: { appStoreVersion: v.id } });
|
|
const r = await twice(() => appleScreenshots(p.ctx(['--platform', 'macos'])));
|
|
expect(r.first.filter(x => x === 'POST /v1/appScreenshotSets')).toHaveLength(2);
|
|
expect(r.second).toEqual([]);
|
|
const sets = s.asc.all('appScreenshotSets');
|
|
expect(sets.map(x => x.attributes.screenshotDisplayType)).toEqual(['APP_DESKTOP', 'APP_DESKTOP']);
|
|
expect(sets.every(x => JSON.stringify(x).includes('loc-ver-mac-'))).toBe(true);
|
|
expect(count('appScreenshots')).toBe(4);
|
|
});
|
|
|
|
test('apple cpp create, events create, testflight groups, review-details, bundle, offer-codes, ppo create', async () => {
|
|
const p = project({ ...CONFIG, shots: { variants: { b: {} } } as never }, { '.storebound/credentials.json': JSON.stringify({ reviewContact: { firstName: 'Ex', lastName: 'Ample', phone: '+1 555 0100', email: 'review@example.com' } }) });
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
|
|
for (const [name, run] of [
|
|
['cpp', () => appleCpp(p.ctx(['create']))],
|
|
['events', () => appleEvents(p.ctx(['create']))],
|
|
['testflight', () => appleTestflight(p.ctx(['groups', 'sync']))],
|
|
['review-details', () => appleReviewDetails(p.ctx([]))],
|
|
['bundle', () => appleBundle(p.ctx([]))],
|
|
['offer-codes', () => appleOfferCodes(p.ctx([]))],
|
|
['ppo', () => applePpo(p.ctx(['create', '--variant', 'b']))],
|
|
] as [string, () => Promise<void>][]) {
|
|
const r = await twice(run);
|
|
expect([name, r.first.length > 0]).toEqual([name, true]);
|
|
expect([name, r.second]).toEqual([name, []]);
|
|
}
|
|
expect(count('appCustomProductPages')).toBe(1);
|
|
expect(count('appEvents')).toBe(1);
|
|
expect(count('appEventLocalizations')).toBe(1);
|
|
expect(count('betaGroups')).toBe(2);
|
|
expect(count('appStoreReviewDetails')).toBe(1);
|
|
expect(count('bundleIds')).toBe(1);
|
|
expect(count('inAppPurchaseOfferCodes')).toBe(1);
|
|
expect(count('appStoreVersionExperiments')).toBe(1);
|
|
expect(count('appStoreVersionExperimentTreatments')).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('App Store Connect: more commands, twice', () => {
|
|
test('apple subscriptions: group, subscription, localizations, availability, prices once', async () => {
|
|
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
|
|
{ id: 'com.example.notes.monthly', period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'Monthly', description: 'Pro every month.' }, de: { title: 'Monatlich', description: 'Pro jeden Monat.' } } },
|
|
] } as never] };
|
|
const p = project(cfg);
|
|
const r = await twice(() => appleSubscriptions(p.ctx(['--yes'])));
|
|
expect(r.first).toContain('POST /v1/subscriptions');
|
|
expect(r.second).toEqual([]);
|
|
expect([count('subscriptionGroups'), count('subscriptions'), count('subscriptionLocalizations'), count('subscriptionAvailabilities'), count('subscriptionPrices')]).toEqual([1, 1, 2, 1, 3]);
|
|
});
|
|
|
|
test('apple iap-submit --no-submit: one draft, the item once', async () => {
|
|
const p = project({ ...CONFIG, products: [CONFIG.products![1]!] });
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-old', attributes: { productId: 'com.example.notes.old', inAppPurchaseType: 'CONSUMABLE', state: 'APPROVED' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-tip', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-tip' } });
|
|
const r = await twice(() => appleIapSubmit(p.ctx(['--no-submit', '--yes'])));
|
|
expect(r.first).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems']);
|
|
expect(r.second).toEqual([]);
|
|
expect([count('reviewSubmissions'), count('reviewSubmissionItems')]).toEqual([1, 1]);
|
|
});
|
|
|
|
test('apple release --phased on, apple encryption, apple adhoc register', async () => {
|
|
const p = project({ ...CONFIG, apple: { ...CONFIG.apple!, usesNonExemptEncryption: false } }, { '.storebound/devices.csv': 'name,udid,platform\nTest phone,00008101-001A2B3C4D5E6F70,IOS\n' });
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-2', attributes: { versionString: '1.1.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', usesNonExemptEncryption: null, expired: false }, rels: { app: APP } });
|
|
for (const [name, run] of [
|
|
['release', () => appleRelease(p.ctx(['--phased', 'on']))],
|
|
['encryption', () => appleEncryption(p.ctx([]))],
|
|
['adhoc', () => appleAdhoc(p.ctx(['register', '--yes']))],
|
|
] as [string, () => Promise<void>][]) {
|
|
const r = await twice(run);
|
|
expect([name, r.first.length]).toEqual([name, 1]);
|
|
expect([name, r.second]).toEqual([name, []]);
|
|
}
|
|
expect([count('appStoreVersionPhasedReleases'), count('devices')]).toEqual([1, 1]);
|
|
});
|
|
});
|
|
|
|
describe('Google Play: a second run writes nothing', () => {
|
|
test('play products: created, activated, then unchanged', async () => {
|
|
const p = project(CONFIG);
|
|
const r = await twice(() => playProducts(p.ctx([])));
|
|
expect(r.first.some(x => x.startsWith('PATCH /androidpublisher/v3/applications/com.example.notes/onetimeproducts/'))).toBe(true);
|
|
expect(r.second).toEqual([]);
|
|
expect(s.play.products.size).toBe(2);
|
|
});
|
|
|
|
test('play images: uploaded once; the second run compares and commits nothing', async () => {
|
|
const files: Record<string, string | Uint8Array> = { 'store/listing.md': LISTING, 'store/icons/play-512.png': png(1) };
|
|
files['store/renders/en/feature/1024x500.png'] = png(2);
|
|
for (const [sub, base] of [['play', 10], ['tab7', 20], ['tab10', 30]] as const) for (const n of [1, 2]) files[`store/renders/en/${sub}/0${n}.png`] = png(base + n);
|
|
const p = project(CONFIG, files);
|
|
const r = await twice(() => playImages(p.ctx([])));
|
|
expect(r.first.filter(x => x.startsWith('POST /upload/'))).toHaveLength(8);
|
|
expect(r.second).toEqual([]);
|
|
expect(s.play.committed).toHaveLength(1);
|
|
expect(s.play.edits.size).toBe(0);
|
|
});
|
|
|
|
test('play subscriptions: created with base plan and intro offer once', async () => {
|
|
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
|
|
{ id: 'com.example.notes.monthly', period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'Monthly', description: 'Pro every month.', benefits: ['Sync'] } }, intro: { type: 'free', duration: 'P1W' } },
|
|
] } as never] };
|
|
const p = project(cfg);
|
|
const r = await twice(() => playSubscriptions(p.ctx(['--yes'])));
|
|
expect(r.first.some(x => x.endsWith('/subscriptions'))).toBe(true);
|
|
expect(r.second).toEqual([]);
|
|
expect(s.play.subs.size).toBe(1);
|
|
expect(s.play.offers.size).toBe(1);
|
|
});
|
|
|
|
test('play rollout and play release --version-code: the same state is not committed again', async () => {
|
|
s.play.live.tracks.production = { releases: [{ versionCodes: ['41'], status: 'inProgress', userFraction: 0.1 }] };
|
|
s.play.live.bundles = [41, 42];
|
|
const p = project(CONFIG, { 'store/listing.md': LISTING });
|
|
let r = await twice(() => playRollout(p.ctx(['--fraction', '0.2', '--yes'])));
|
|
expect(r.first).toContain('PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-1/tracks/production');
|
|
expect(r.second).toEqual([]);
|
|
r = await twice(() => playRelease(p.ctx(['--version-code', '42', '--internal', 'completed'])));
|
|
expect(r.first.some(x => x.endsWith('/tracks/internal'))).toBe(true);
|
|
expect(r.second).toEqual([]);
|
|
expect(s.play.edits.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
const APPLE_MD = '# App Store\n\n## en-US · English\n\n### subtitle\n```\nNotes that sync\n```\n\n### keywords\n```\nnotes,lists\n```\n\n### description\n```\nNotes for everyone.\n```\n\n### promotionalText\n```\nNow with lists.\n```\n\n### whatsNew\n```\nFaster sync.\n```\n';
|
|
|
|
/** An app with a released 1.0.0, an editable app info and a processed build 9 of 1.1.0. */
|
|
function appleListingStore(): void {
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-old', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'appInfos', id: 'info-1', attributes: { state: 'PREPARE_FOR_SUBMISSION' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'builds', id: 'b-9', attributes: { version: '9', processingState: 'VALID' }, rels: { app: APP } });
|
|
}
|
|
|
|
describe('listing, appinfo, data safety and the owner commands: a second run writes nothing', () => {
|
|
test('apple listing: version created (re-checked first), build attached, localizations created once; then nothing', async () => {
|
|
appleListingStore();
|
|
const p = project(CONFIG, { 'store/appstore.md': APPLE_MD });
|
|
const r = await twice(() => appleListing(p.ctx(['--version', '1.1.0', '--build', '9'])));
|
|
expect(r.first).toEqual(['POST /v1/appStoreVersions', expect.stringMatching(/^PATCH \/v1\/appStoreVersions\/.+\/relationships\/build$/), 'POST /v1/appInfoLocalizations', 'POST /v1/appStoreVersionLocalizations']);
|
|
expect(r.second).toEqual([]);
|
|
expect([count('appStoreVersions'), count('appInfoLocalizations'), count('appStoreVersionLocalizations')]).toEqual([2, 1, 1]);
|
|
// a changed text: only that field of that localization is PATCHed, the build is not attached again
|
|
const q = project(CONFIG, { 'store/appstore.md': APPLE_MD.replace('Notes for everyone.', 'Notes for all.') });
|
|
const before = s.calls.length;
|
|
await capture(() => appleListing(q.ctx(['--version', '1.1.0', '--build', '9'])));
|
|
const w = writes(s.calls.slice(before));
|
|
expect(w.map(c => `${c.method} ${c.path.replace(/\/[^/]+$/, '/:id')}`)).toEqual(['PATCH /v1/appStoreVersionLocalizations/:id']);
|
|
expect(Object.keys((w[0]!.body as { data: { attributes: object } }).data.attributes)).toEqual(['description']);
|
|
});
|
|
|
|
test('apple listing: a lost answer on the version create is found by the re-check, not created twice', async () => {
|
|
appleListingStore();
|
|
const p = project(CONFIG, { 'store/appstore.md': APPLE_MD });
|
|
s.faults.push({ method: 'POST', match: /^\/v1\/appStoreVersions$/, when: 'after', status: 502 });
|
|
const r = await capture(() => appleListing(p.ctx(['--version', '1.1.0'])));
|
|
expect(r.exit).toBe(null);
|
|
expect(r.err).toContain('no clear answer');
|
|
expect(count('appStoreVersions')).toBe(2);
|
|
expect(s.calls.filter(c => c.method === 'POST' && c.path === '/v1/appStoreVersions')).toHaveLength(1);
|
|
expect(count('appStoreVersionLocalizations')).toBe(1);
|
|
});
|
|
|
|
test('apple listing: a localization the list does not show yet answers 409; it is used and updated, not created twice', async () => {
|
|
appleListingStore();
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-2', attributes: { versionString: '1.1.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS', copyright: `${new Date().getFullYear()} Example Notes`, releaseType: 'MANUAL' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'vloc-en', attributes: { locale: 'en-US', description: 'Old.' }, rels: { appStoreVersion: 'ver-2' }, hiddenReads: 1 } as never);
|
|
const p = project(CONFIG, { 'store/appstore.md': APPLE_MD });
|
|
const r = await capture(() => appleListing(p.ctx(['--version', '1.1.0'])));
|
|
expect(r.exit).toBe(null);
|
|
expect(r.out).toContain('already exists, using it');
|
|
expect(count('appStoreVersionLocalizations')).toBe(1);
|
|
expect(s.asc.res.get('vloc-en')!.attributes.description).toBe('Notes for everyone.');
|
|
});
|
|
|
|
test('play listing: one committed edit with only what differs; the second run opens no write edit and commits nothing', async () => {
|
|
s.play.live.listings['de-DE'] = { language: 'de-DE', title: 'Notizen' };
|
|
s.play.live.listings['en-US'] = { language: 'en-US', title: 'Old' };
|
|
const p = project(CONFIG, { 'store/listing.md': LISTING });
|
|
const r = await twice(() => playListing(p.ctx([])));
|
|
expect(r.first).toEqual(['PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-2/details', 'PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-2/listings/en-US',
|
|
'POST /androidpublisher/v3/applications/com.example.notes/edits/edit-2:commit']);
|
|
expect(r.second).toEqual([]);
|
|
expect(s.play.committed).toHaveLength(1);
|
|
expect(s.play.edits.size).toBe(0);
|
|
expect(s.play.live.listings['de-DE']).toEqual({ language: 'de-DE', title: 'Notizen' }); // a language not in the file is left alone
|
|
});
|
|
|
|
// A new Play language (tr-TR next to a live en-US): the commit is refused 403 "too few screenshots" unless the
|
|
// screenshots, icon and feature graphic travel in the same edit (pitfall 102)
|
|
const trConfig = (): ShipConfig => ({ ...CONFIG, languages: [{ id: 'en', play: 'en-US' }, { id: 'tr', play: 'tr-TR' }] as never });
|
|
const trListing = LISTING + ['', '## Turkish', '<!-- play: tr-TR -->', '', '### Play short description', '```', 'Notlar.', '```', '', '### Play full description', '```', 'Herkes icin notlar.', '```', ''].join('\n');
|
|
const renders = (lang: string, base: number, tablets = false): Record<string, Uint8Array> => {
|
|
const f: Record<string, Uint8Array> = { [`store/renders/${lang}/feature/1024x500.png`]: png(base) };
|
|
for (const n of [1, 2]) { f[`store/renders/${lang}/play/0${n}.png`] = png(base + n); if (tablets) for (const t of ['tab7', 'tab10']) f[`store/renders/${lang}/${t}/0${n}.png`] = png(base + n + 5); }
|
|
return f;
|
|
};
|
|
const liveEn = () => {
|
|
s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Notes', shortDescription: 'Notes.', fullDescription: 'Notes for everyone.' };
|
|
s.play.live.details = { defaultLanguage: 'en-US', contactEmail: 'support@example.com', contactWebsite: 'https://example.com' };
|
|
s.play.requireNewLangAssets = true;
|
|
};
|
|
|
|
test('play listing: a new language uploads icon, feature graphic and phone screenshots in the same edit, so the commit passes', async () => {
|
|
liveEn();
|
|
const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('tr', 40) });
|
|
const r = await capture(() => playListing(p.ctx([])));
|
|
expect(r.exit).toBe(null);
|
|
expect(s.play.committed).toHaveLength(1);
|
|
expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2);
|
|
expect(s.play.live.images['tr-TR/icon']).toHaveLength(1);
|
|
expect(s.play.live.images['tr-TR/featureGraphic']).toHaveLength(1);
|
|
});
|
|
|
|
test('play listing: a new language without its own screenshots uses the default language images; none anywhere is refused in the dry run, nothing written', async () => {
|
|
liveEn();
|
|
const base = { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1) };
|
|
const ok = project(trConfig(), { ...base, ...renders('en', 10) });
|
|
const r1 = await capture(() => playListing(ok.ctx([])));
|
|
expect(r1.exit).toBe(null);
|
|
expect(r1.out).toContain("no phoneScreenshots of its own, using en-US's");
|
|
expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2);
|
|
s.play.live.listings = { 'en-US': s.play.live.listings['en-US']! };
|
|
s.play.live.images = {};
|
|
const bad = project(trConfig(), base);
|
|
const before = s.play.committed.length;
|
|
const mark = s.calls.length;
|
|
await expect(playListing(bad.ctx(['--dry']))).rejects.toThrow('tr-TR: no phoneScreenshots');
|
|
expect(s.play.committed).toHaveLength(before);
|
|
expect(writes(s.calls.slice(mark))).toEqual([]);
|
|
});
|
|
|
|
test('play listing: the dry run names the uploads of a new language; tablet sets are required when the default language has them on Play', async () => {
|
|
liveEn();
|
|
s.play.requireTablets = true;
|
|
s.play.live.images['en-US/sevenInchScreenshots'] = ['x', 'y'];
|
|
const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('tr', 40), ...renders('en', 10, true) });
|
|
const r = await capture(() => playListing(p.ctx(['--dry'])));
|
|
expect(r.out).toContain('tr-TR is new on Play: uploads');
|
|
expect(r.out).toContain('sevenInchScreenshots (2)');
|
|
expect(s.play.committed).toEqual([]);
|
|
});
|
|
|
|
test('play images: a language with text in the listing file but no Play listing yet is created with its images, commit passes', async () => {
|
|
liveEn();
|
|
const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('en', 10), ...renders('tr', 40) });
|
|
const r = await capture(() => playImages(p.ctx([])));
|
|
expect(r.exit).toBe(null);
|
|
expect(r.out).toContain('tr-TR: no Play listing yet, this run creates it');
|
|
expect(s.play.live.listings['tr-TR']!.title).toBeDefined();
|
|
expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2);
|
|
});
|
|
|
|
test('play listing: the store reached the wanted state between plan and edit: the edit is deleted, not committed', async () => {
|
|
const p = project(CONFIG, { 'store/listing.md': LISTING });
|
|
// the plan sees an empty listing; right after it (before the write edit opens) another run makes it current
|
|
const orig = s.play.handle.bind(s.play);
|
|
let planned = false;
|
|
s.play.handle = (method, url, body) => {
|
|
if (method === 'DELETE' && /\/edits\/edit-1$/.test(url) && !planned) {
|
|
planned = true;
|
|
s.play.live.details = { defaultLanguage: 'en-US', contactEmail: 'support@example.com', contactWebsite: 'https://example.com' };
|
|
s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Notes', shortDescription: 'Notes.', fullDescription: 'Notes for everyone.' };
|
|
}
|
|
return orig(method, url, body);
|
|
};
|
|
const r = await capture(() => playListing(p.ctx([])));
|
|
expect(r.out).toContain('nothing committed');
|
|
expect(s.play.committed).toEqual([]);
|
|
expect(writes(s.calls)).toEqual([]);
|
|
expect(s.play.edits.size).toBe(0);
|
|
});
|
|
|
|
test('apple appinfo: categories, age rating, content rights, price and availability set once, then compared and left', async () => {
|
|
s.asc.put({ type: 'appInfos', id: 'info-1', attributes: { state: 'PREPARE_FOR_SUBMISSION' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'ageRatingDeclarations', id: 'age-1', attributes: {}, rels: { appInfo: 'info-1' } });
|
|
s.asc.put({ type: 'appPricePoints', id: 'app-pp-free', attributes: { customerPrice: '0.0' }, rels: { app: APP, territory: 'USA' } });
|
|
const cfg: ShipConfig = { ...CONFIG, apple: { ...CONFIG.apple!, info: { categories: { primary: 'PRODUCTIVITY' }, contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT', ageRating: { advertising: true } } as never } };
|
|
const p = project(cfg);
|
|
const r = await twice(() => appleAppinfo(p.ctx([])));
|
|
expect(r.first).toEqual(['PATCH /v1/appInfos/info-1', 'PATCH /v1/ageRatingDeclarations/info-1', `PATCH /v1/apps/${APP}`, 'POST /v1/appPriceSchedules', 'POST /v2/appAvailabilities']);
|
|
expect(r.second).toEqual([]);
|
|
expect(s.asc.res.get('age-1')!.attributes.advertising).toBe(true);
|
|
// one answer changed in the config: only the age rating is PATCHed
|
|
const q = project({ ...cfg, apple: { ...cfg.apple!, info: { ...cfg.apple!.info!, ageRating: { advertising: true, contests: 'INFREQUENT_OR_MILD' } } as never } });
|
|
const before = s.calls.length;
|
|
await capture(() => appleAppinfo(q.ctx([])));
|
|
expect(writes(s.calls.slice(before)).map(c => `${c.method} ${c.path}`)).toEqual(['PATCH /v1/ageRatingDeclarations/info-1']);
|
|
});
|
|
|
|
test('play data-safety: the same CSV is not sent again (hash in .storebound/); a changed one or --force is', async () => {
|
|
const ds = { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: false, collect: ['analytics'] } } };
|
|
const p = project({ ...CONFIG, dataSafety: ds as never });
|
|
const r = await twice(() => playDataSafety(p.ctx([])));
|
|
expect(r.first).toEqual(['POST /androidpublisher/v3/applications/com.example.notes/dataSafety']);
|
|
expect(r.second).toEqual([]);
|
|
expect(existsSync(join(p.root, '.storebound', 'data-safety.sha256'))).toBe(true);
|
|
let before = s.calls.length;
|
|
await capture(() => playDataSafety(p.ctx(['--force'])));
|
|
expect(writes(s.calls.slice(before))).toHaveLength(1);
|
|
const q = project({ ...CONFIG, dataSafety: { ...ds, data: { ...ds.data, PSL_EMAIL: { shared: false, collect: ['functionality'] } } } as never });
|
|
mkdirSync(join(q.root, '.storebound'), { recursive: true });
|
|
writeFileSync(join(q.root, '.storebound', 'data-safety.sha256'), readFileSync(join(p.root, '.storebound', 'data-safety.sha256')));
|
|
before = s.calls.length;
|
|
await capture(() => playDataSafety(q.ctx([])));
|
|
expect(writes(s.calls.slice(before))).toHaveLength(1);
|
|
expect(s.play.dataSafety).toHaveLength(3);
|
|
});
|
|
|
|
test('play data-safety: a missing conditional row stops --dry and the real run before any POST', async () => {
|
|
// STAR DASH: accounts with a username, no deletion URL in the CSV -> Play 400 "Response missing for PSL_ACCOUNT_DELETION_URL"
|
|
const ds = { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
|
|
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } };
|
|
const p = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'] }, dataSafety: ds as never });
|
|
const before = s.calls.length;
|
|
await expect(capture(() => playDataSafety(p.ctx(['--dry'])))).rejects.toThrow('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl');
|
|
await expect(capture(() => playDataSafety(p.ctx([])))).rejects.toThrow('PSL_ACCOUNT_DELETION_URL');
|
|
expect(writes(s.calls.slice(before))).toEqual([]);
|
|
const q = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'], deletionUrl: 'https://example.com/delete-account' }, dataSafety: ds as never });
|
|
await capture(() => playDataSafety(q.ctx([])));
|
|
expect(s.play.dataSafety.at(-1)).toContain('PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,');
|
|
// a single choice: the picked option carries TRUE (a bare FALSE was Play's 400 "Line 7: Response ID missing")
|
|
// a delete-account page answers the general deletion question with yes
|
|
expect(s.play.dataSafety.at(-1)).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,');
|
|
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,');
|
|
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_DATA_COLLECTION_USER_REQUEST_DELETE');
|
|
});
|
|
|
|
test('apple promoted: created and ordered once', async () => {
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-pro', attributes: { productId: 'com.example.notes.pro', state: 'APPROVED' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', state: 'APPROVED' }, rels: { app: APP } });
|
|
const p = project({ ...CONFIG, apple: { ...CONFIG.apple!, promoted: { products: ['com.example.notes.tip', 'com.example.notes.pro'] } } });
|
|
const r = await twice(() => applePromoted(p.ctx(['--yes'])));
|
|
expect(r.first.filter(x => x === 'POST /v1/promotedPurchases')).toHaveLength(2);
|
|
expect(r.second).toEqual([]);
|
|
expect(count('promotedPurchases')).toBe(2);
|
|
});
|
|
|
|
test('apple win-back: the missing offer is created once', async () => {
|
|
const g = s.asc.put({ type: 'subscriptionGroups', id: 'grp-1', attributes: { referenceName: 'Pro' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'subscriptions', id: 'sub-1', attributes: { productId: 'com.example.notes.monthly', state: 'APPROVED' }, rels: { subscriptionGroup: g.id } });
|
|
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
|
|
{ id: 'com.example.notes.monthly', period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'Monthly', description: 'Pro every month.' } },
|
|
winBack: [{ id: 'come-back', type: 'free', duration: 'P1M', paidMonths: 1, lapsedMonths: { min: 1, max: 6 }, start: '2099-01-01' }] },
|
|
] } as never] };
|
|
const p = project(cfg);
|
|
const r = await twice(() => appleWinBack(p.ctx(['--yes'])));
|
|
expect(r.first).toEqual(['POST /v1/winBackOffers']);
|
|
expect(r.second).toEqual([]);
|
|
expect(count('winBackOffers')).toBe(1);
|
|
});
|
|
|
|
test('apple preorder enable: territories set once; the same date again changes nothing', async () => {
|
|
const av = s.asc.put({ type: 'appAvailabilities', id: 'avail-1', attributes: { availableInNewTerritories: true }, rels: { app: APP } });
|
|
for (const t of ['USA', 'DEU']) s.asc.put({ type: 'territoryAvailabilities', id: `ta-${t}`, attributes: { available: true, contentStatuses: ['CANNOT_SELL'] }, rels: { appAvailability: av.id, territory: t } });
|
|
const p = project(CONFIG);
|
|
const date = new Date(Date.now() + 30 * 86400_000).toISOString().slice(0, 10);
|
|
const r = await twice(() => applePreorder(p.ctx(['enable', '--release-date', date, '--yes'])));
|
|
expect(r.first).toEqual(['PATCH /v1/territoryAvailabilities/ta-USA', 'PATCH /v1/territoryAvailabilities/ta-DEU']);
|
|
expect(r.second).toEqual([]);
|
|
});
|
|
|
|
test('apple testflight testers: created in their groups once', async () => {
|
|
s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } });
|
|
const p = project(CONFIG, { '.storebound/testers.csv': 'email,first_name,groups\ntester1@example.com,Ex,Friends\ntester2@example.com,Am,Friends\n' });
|
|
const r = await twice(() => appleTestflight(p.ctx(['testers', '--yes'])));
|
|
expect(r.first).toEqual(['POST /v1/betaTesters', 'POST /v1/betaTesters']);
|
|
expect(r.second).toEqual([]);
|
|
expect(count('betaTesters')).toBe(2);
|
|
});
|
|
|
|
test('play upload: uploaded once; the same versionCode on the same track is not sent again', async () => {
|
|
const p = project(CONFIG, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42) });
|
|
const r = await twice(() => playUpload(p.ctx(['--yes'])));
|
|
expect(r.first).toEqual(['POST /upload/androidpublisher/v3/applications/com.example.notes/edits/edit-2/bundles', 'PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-2/tracks/internal',
|
|
'POST /androidpublisher/v3/applications/com.example.notes/edits/edit-2:commit']);
|
|
expect(r.second).toEqual([]);
|
|
expect(s.play.committed).toHaveLength(1);
|
|
});
|
|
|
|
test('prices import (Play): changed regions written once, then the diff is empty', async () => {
|
|
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] }, { 'store/prices.csv': 'product,store,region,currency,price\ncom.example.notes.pro,play,DE,EUR,3.49\n' });
|
|
await capture(() => playProducts(p.ctx([])));
|
|
const r = await twice(() => prices(p.ctx(['import', '--store', 'play', '--yes', '--allow-price-changes'])));
|
|
expect(r.first.some(x => x.startsWith('PATCH /androidpublisher/v3/applications/com.example.notes/onetimeproducts/'))).toBe(true);
|
|
expect(r.second).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('crash midway and unclear answers', () => {
|
|
test('screenshot set: an upload that dies on the 3rd file leaves a prefix; the re-run finishes it without duplicates, in order', async () => {
|
|
const files: Record<string, Uint8Array> = {};
|
|
for (const n of [1, 2, 3, 4]) files[`store/renders/en/ios69/0${n}.png`] = png(n);
|
|
const cfg = { ...CONFIG, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] };
|
|
const p = project(cfg, files);
|
|
const v = s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' }, rels: { appStoreVersion: v.id } });
|
|
// an older set: two screenshots, the first already the wanted 01.png
|
|
const set = s.asc.put({ type: 'appScreenshotSets', id: 'set-old', attributes: { screenshotDisplayType: 'APP_IPHONE_67' }, rels: { appStoreVersionLocalization: 'loc-en' } });
|
|
const md5 = (b: Uint8Array) => new Bun.CryptoHasher('md5').update(b).digest('hex');
|
|
s.asc.put({ type: 'appScreenshots', id: 'shot-a', attributes: { sourceFileChecksum: md5(png(1)), assetDeliveryState: { state: 'COMPLETE' }, fileName: '01.png' }, rels: { appScreenshotSet: set.id } });
|
|
s.asc.put({ type: 'appScreenshots', id: 'shot-x', attributes: { sourceFileChecksum: 'stale', assetDeliveryState: { state: 'COMPLETE' }, fileName: 'old.png' }, rels: { appScreenshotSet: set.id } });
|
|
s.failPart = n => n === 2 || n === 3; // 03.png fails, and its one retry too
|
|
const r1 = await capture(() => appleScreenshots(p.ctx(['--only', 'en-US']))).catch(e => ({ out: String(e), err: '', exit: 1 }));
|
|
expect(r1.out + r1.err).toContain('03.png part 0 -> 400');
|
|
expect(r1.out + r1.err).toContain('nothing committed');
|
|
// the failed reservations were deleted, not left AWAITING_UPLOAD
|
|
expect(s.asc.children(set.id, 'appScreenshots').filter(x => (x.attributes.assetDeliveryState as { state: string } | undefined)?.state !== 'COMPLETE')).toEqual([]);
|
|
expect(s.asc.children(set.id, 'appScreenshots').map(x => x.id)).toContain('shot-a');
|
|
s.failPart = undefined;
|
|
const r2 = await capture(() => appleScreenshots(p.ctx(['--only', 'en-US'])));
|
|
expect(r2.exit).toBe(null);
|
|
const shots = s.asc.children(set.id, 'appScreenshots');
|
|
expect(shots.map(x => x.attributes.sourceFileChecksum)).toEqual([1, 2, 3, 4].map(n => md5(png(n))));
|
|
expect(shots.every(x => (x.attributes.assetDeliveryState as { state: string }).state === 'COMPLETE')).toBe(true);
|
|
expect(shots[0]!.id).toBe('shot-a'); // the matching first screenshot was kept, not uploaded again
|
|
expect(count('appScreenshotSets')).toBe(1);
|
|
const before = s.calls.length;
|
|
await capture(() => appleScreenshots(p.ctx(['--only', 'en-US'])));
|
|
expect(writes(s.calls.slice(before))).toEqual([]);
|
|
});
|
|
|
|
test('IAP create: a 5xx after Apple created it is found, not created again', async () => {
|
|
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
|
|
s.faults.push({ method: 'POST', match: /^\/v2\/inAppPurchases$/, when: 'after', status: 502 });
|
|
const r = await capture(() => appleIap(p.ctx([])));
|
|
expect(r.exit).toBe(null);
|
|
expect(r.err).toContain('no clear answer');
|
|
expect(count('inAppPurchases')).toBe(1);
|
|
expect(s.calls.filter(c => c.method === 'POST' && c.path === '/v2/inAppPurchases')).toHaveLength(1);
|
|
expect(count('inAppPurchaseLocalizations')).toBe(2);
|
|
});
|
|
|
|
test('IAP create: a 5xx before anything happened is tried once more (product ids are unique, so that is safe)', async () => {
|
|
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
|
|
s.faults.push({ method: 'POST', match: /^\/v2\/inAppPurchases$/, when: 'before', status: 503 });
|
|
const r = await capture(() => appleIap(p.ctx([])));
|
|
expect(r.exit).toBe(null);
|
|
expect(count('inAppPurchases')).toBe(1);
|
|
expect(s.calls.filter(c => c.method === 'POST' && c.path === '/v2/inAppPurchases')).toHaveLength(2);
|
|
});
|
|
|
|
test('IAP create: crash right after the create (process gone); the re-run during the list lag uses the journal, no duplicate', async () => {
|
|
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
|
|
// first run: the product is created, then the run dies on the localization
|
|
s.faults.push({ method: 'POST', match: /^\/v1\/inAppPurchaseLocalizations$/, when: 'before', status: 400 });
|
|
s.asc.lag.inAppPurchases = 50;
|
|
await capture(() => appleIap(p.ctx([]))).catch(e => expect(String(e)).toContain('injected'));
|
|
expect(count('inAppPurchases')).toBe(1);
|
|
expect(Object.keys(readJournal(p.root))).toEqual(['apple:com.example.notes:iap:com.example.notes.pro']);
|
|
const r = await capture(() => appleIap(p.ctx([])));
|
|
expect(r.out).toContain('does not show it yet, using it');
|
|
expect(count('inAppPurchases')).toBe(1);
|
|
expect(count('inAppPurchaseLocalizations')).toBe(2);
|
|
});
|
|
|
|
test('a 409 duplicate is treated as found (no second create, no error)', async () => {
|
|
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-old', attributes: { productId: 'com.example.notes.pro', state: 'MISSING_METADATA' }, rels: { app: APP }, hiddenReads: 1 } as never);
|
|
const r = await capture(() => appleIap(p.ctx([])));
|
|
expect(r.exit).toBe(null);
|
|
expect(r.out).toContain('already exists, using it');
|
|
expect(count('inAppPurchases')).toBe(1);
|
|
});
|
|
|
|
test('apple subscriptions: a lost answer on the subscription create is found again; the second run creates nothing', async () => {
|
|
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
|
|
{ id: 'com.example.notes.monthly', period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'Monthly', description: 'Pro every month.' } } },
|
|
] } as never] };
|
|
const p = project(cfg);
|
|
s.faults.push({ method: 'POST', match: /^\/v1\/subscriptions$/, when: 'after', status: 504 });
|
|
await capture(() => appleSubscriptions(p.ctx(['--yes'])));
|
|
expect(count('subscriptions')).toBe(1);
|
|
expect(count('subscriptionGroups')).toBe(1);
|
|
const before = s.calls.length;
|
|
await capture(() => appleSubscriptions(p.ctx(['--yes'])));
|
|
expect(writes(s.calls.slice(before)).filter(c => c.method === 'POST' && /subscription(Group)?s$|Localizations$/.test(c.path))).toEqual([]);
|
|
expect(count('subscriptions')).toBe(1);
|
|
expect(count('subscriptionLocalizations')).toBe(1);
|
|
});
|
|
|
|
test('Play subscription create: 409 ALREADY_EXISTS is found, offers are not created twice', async () => {
|
|
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
|
|
{ id: 'com.example.notes.yearly', period: 'P1Y', level: 1, usd: 29.99, listings: { en: { title: 'Yearly', description: 'Pro every year.', benefits: ['Sync'] } } },
|
|
] } as never] };
|
|
const p = project(cfg);
|
|
s.faults.push({ method: 'POST', match: /\/subscriptions\?productId=/, when: 'after', status: 500 });
|
|
const r = await capture(() => playSubscriptions(p.ctx(['--yes'])));
|
|
expect(r.err).toContain('no clear answer');
|
|
expect(s.play.subs.size).toBe(1);
|
|
expect(s.calls.filter(c => c.method === 'POST' && /\/subscriptions\?productId=/.test(c.path))).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('Play edits', () => {
|
|
test('an error inside the edit deletes it; nothing is committed; no second edit is ever open', async () => {
|
|
const play = await playClient(project().root, 'com.example.notes');
|
|
await expect(play.edit(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
|
expect(s.play.edits.size).toBe(0);
|
|
expect(s.play.committed).toEqual([]);
|
|
await expect(play.edit(async () => { await play.read(async () => 1); })).rejects.toThrow('already open');
|
|
expect(s.play.edits.size).toBe(0);
|
|
});
|
|
|
|
test('an upload without a clear answer inside an edit: the edit is discarded and redone from scratch, one image', async () => {
|
|
const play = await playClient(project().root, 'com.example.notes');
|
|
s.faults.push({ method: 'POST', match: /phoneScreenshots\?uploadType=media/, when: 'after', status: 503 });
|
|
await play.edit(async id => {
|
|
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/en-US/phoneScreenshots?uploadType=media`, png(1), { 'Content-Type': 'image/png' });
|
|
});
|
|
expect(s.play.live.images['en-US/phoneScreenshots']).toHaveLength(1);
|
|
expect(s.play.committed).toHaveLength(1);
|
|
expect(s.play.edits.size).toBe(0);
|
|
});
|
|
|
|
test('a commit without a clear answer is never redone', async () => {
|
|
const play = await playClient(project().root, 'com.example.notes');
|
|
s.faults.push({ method: 'POST', match: /:commit$/, when: 'after', status: 502 });
|
|
let runs = 0;
|
|
await expect(play.edit(async id => { runs++; await play.call('PUT', `${play.API}/edits/${id}/details`, { defaultLanguage: 'en-US' }); })).rejects.toThrow('may be live');
|
|
expect(runs).toBe(1);
|
|
expect(s.play.committed).toHaveLength(1);
|
|
});
|
|
|
|
test('unchanged(): the edit is deleted, not committed', async () => {
|
|
const play = await playClient(project().root, 'com.example.notes');
|
|
await play.edit(async (_id, ctl) => { ctl.unchanged(); });
|
|
expect(s.play.committed).toEqual([]);
|
|
expect(s.play.edits.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('helpers', () => {
|
|
test('retrySafe: POST creates are not retried, idempotent actions are', () => {
|
|
expect(retrySafe('GET', '/v1/apps')).toBe(true);
|
|
expect(retrySafe('PATCH', '/v1/apps/1')).toBe(true);
|
|
expect(retrySafe('DELETE', '/v1/appScreenshots/1')).toBe(true);
|
|
expect(retrySafe('POST', '/v2/inAppPurchases')).toBe(false);
|
|
expect(retrySafe('POST', '/v1/appScreenshots')).toBe(false);
|
|
expect(retrySafe('POST', '/v1/betaGroups/1/relationships/builds')).toBe(true);
|
|
expect(retrySafe('POST', '/v1/inAppPurchasePriceSchedules')).toBe(true);
|
|
const play = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.notes';
|
|
expect(retrySafe('POST', `${play}/edits`)).toBe(true);
|
|
expect(retrySafe('POST', `${play}/edits/e1:commit`)).toBe(false);
|
|
expect(retrySafe('POST', `${play}/edits/e1:validate`)).toBe(true);
|
|
expect(retrySafe('POST', `${play}/pricing:convertRegionPrices`)).toBe(true);
|
|
expect(retrySafe('POST', `${play}/subscriptions?productId=x`)).toBe(false);
|
|
expect(retrySafe('POST', 'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/com.example.notes/edits/e1/bundles?uploadType=media')).toBe(false);
|
|
});
|
|
|
|
test('isConflict: duplicates only, not Apple validation errors', () => {
|
|
expect(isConflict(new Error('POST /v2/inAppPurchases -> 409 {"errors":[{"code":"ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE","detail":"already been used"}]}'))).toBe(true);
|
|
expect(isConflict(new Error('POST /v2/inAppPurchases -> 409 {"errors":[{"code":"ENTITY_ERROR.ATTRIBUTE.INVALID","detail":"name is too long"}]}'))).toBe(false);
|
|
expect(isConflict(new Error('POST /subscriptions -> 409 {"error":{"status":"ALREADY_EXISTS"}}'))).toBe(true);
|
|
expect(isConflict(new Error('RevenueCat POST /v2/projects/p/products: 409 {"message":"resource already exists"}'))).toBe(true);
|
|
expect(isConflict(new Error('GET /v1/apps -> 404'))).toBe(false);
|
|
});
|
|
|
|
test('createOnce / findOrCreate: unknown outcome without a unique key stops instead of creating twice', async () => {
|
|
let made = 0;
|
|
const lost = () => { made++; throw new AmbiguousWriteError('POST /v1/appEvents -> 504'); };
|
|
await expect(createOnce({ what: 'event', find: async () => null, create: lost })).rejects.toThrow('not sent again');
|
|
expect(made).toBe(1);
|
|
made = 0;
|
|
await expect(createOnce({ what: 'group', unique: true, find: async () => null, create: lost })).rejects.toThrow('not sent again');
|
|
expect(made).toBe(2);
|
|
const root = project().root;
|
|
const one = await findOrCreate({ what: 'x', find: async () => null, create: async () => ({ id: 'n1' }),
|
|
journal: { root, key: 'k', byId: async () => null, idOf: v => v.id } });
|
|
expect(one).toEqual({ value: { id: 'n1' }, created: true });
|
|
const again = await findOrCreate({ what: 'x', find: async () => null, create: async () => { throw new Error('must not create'); },
|
|
journal: { root, key: 'k', byId: async id => ({ id }), idOf: v => v.id } });
|
|
expect(again).toEqual({ value: { id: 'n1' }, created: false });
|
|
});
|
|
|
|
test('keptPrefix, sameRelease, sameReleases', () => {
|
|
const d = (checksum: string, state = 'COMPLETE') => ({ checksum, state });
|
|
expect(keptPrefix([d('a'), d('b'), d('x')], ['a', 'b', 'c'])).toBe(2);
|
|
expect(keptPrefix([d('a', 'AWAITING_UPLOAD'), d('b')], ['a', 'b'])).toBe(0);
|
|
expect(keptPrefix([], ['a'])).toBe(0);
|
|
const notes = [{ language: 'en-US', text: 'Fixes' }];
|
|
expect(sameRelease([{ versionCodes: ['42'], status: 'completed', releaseNotes: notes }], { versionCodes: ['42'], status: 'completed', releaseNotes: notes })).toBe(true);
|
|
expect(sameRelease([{ versionCodes: ['42'], status: 'draft' }], { versionCodes: ['42'], status: 'completed' })).toBe(false);
|
|
expect(sameReleases([{ status: 'completed', versionCodes: ['1'] }, { versionCodes: ['2'], status: 'inProgress', userFraction: 0.2 }],
|
|
[{ userFraction: 0.2, status: 'inProgress', versionCodes: ['2'] }, { versionCodes: ['1'], status: 'completed' }])).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('one writer per app and store', () => {
|
|
test('a second run is refused with the holder; a dead holder is taken over; --force-unlock removes it', () => {
|
|
const root = project().root;
|
|
const release = acquireLock(root, 'apple-com.example.notes', 'apple iap');
|
|
expect(() => acquireLock(root, 'apple-com.example.notes', 'apple screenshots')).toThrow('`storebound apple iap`');
|
|
release();
|
|
acquireLock(root, 'apple-com.example.notes', 'apple screenshots')();
|
|
// a lock held by another live process
|
|
mkdirSync(join(root, '.storebound', 'locks'), { recursive: true });
|
|
const file = join(root, '.storebound', 'locks', 'play-com.example.notes.lock');
|
|
writeFileSync(file, JSON.stringify({ pid: process.ppid, host: hostname(), command: 'play images', startedAt: new Date().toISOString() }));
|
|
expect(() => acquireLock(root, 'play-com.example.notes', 'play listing')).toThrow('another storebound run is writing to play com.example.notes: `storebound play images`');
|
|
const free = acquireLock(root, 'play-com.example.notes', 'play listing', { force: true });
|
|
expect(JSON.parse(readFileSync(file, 'utf8')).command).toBe('play listing');
|
|
free();
|
|
expect(existsSync(file)).toBe(false);
|
|
expect(staleLock({ pid: 999999999, host: 'h', command: 'x', startedAt: new Date().toISOString() }, Date.now(), 'h', () => false)).toBe(true);
|
|
expect(staleLock({ pid: 1, host: 'other', command: 'x', startedAt: new Date().toISOString() }, Date.now(), 'h', () => false)).toBe(false);
|
|
expect(staleLock({ pid: 1, host: 'other', command: 'x', startedAt: new Date(Date.now() - 13 * 3600_000).toISOString() }, Date.now(), 'h')).toBe(true);
|
|
});
|
|
|
|
test('which runs take a lock', () => {
|
|
const k = (cmd: string, argv: string[]) => lockKeys(cmd, parseArgs(argv), CONFIG);
|
|
expect(k('apple iap', [])).toEqual(['apple-com.example.notes']);
|
|
expect(k('apple iap', ['--dry'])).toEqual([]);
|
|
expect(k('apple testflight', ['status'])).toEqual([]);
|
|
expect(k('apple listing', ['pull'])).toEqual([]);
|
|
expect(k('play images', [])).toEqual(['play-com.example.notes']);
|
|
expect(k('play status', [])).toEqual([]);
|
|
expect(k('prices', ['import'])).toEqual(['apple-com.example.notes', 'play-com.example.notes']);
|
|
expect(k('prices', ['export'])).toEqual([]);
|
|
expect(k('validate', [])).toEqual([]);
|
|
});
|
|
});
|