// 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\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): 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('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 = {}; 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 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']))], ['review-details', () => appleReviewDetails(p.ctx([]))], ['bundle', () => appleBundle(p.ctx([]))], ['offer-codes', () => appleOfferCodes(p.ctx([]))], ['ppo', () => applePpo(p.ctx(['create', '--variant', 'b']))], ] as [string, () => Promise][]) { 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][]) { 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 = { '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' }; 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 }); 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") expect(s.play.dataSafety.at(-1)).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,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']))); 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 = {}; 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; // the 2nd uploaded part (03.png) fails 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(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([]); }); });