Drift on existing items: offer-code and win-back campaigns that differ are named (Apple cannot edit them), in-app event attributes patched on drafts, a removed custom page deep link cleared, config languages without a version localization named, data safety dry run says Play has no read API; appinfo reads /v1/appPriceSchedules/{id}/manualPrices; a new Play language without an icon warns instead of stopping; fake store lists for the new reads

This commit is contained in:
CRTkafa 2026-10-22 20:38:54 +08:00
parent f2b841d735
commit e381bc2256
11 changed files with 206 additions and 10 deletions

View file

@ -98,7 +98,7 @@ export async function planAppInfo(ctx: Ctx, c: Awaited<ReturnType<typeof ascClie
freePricePointId = await findFree();
items.push(planItem('price', null, { price: 'Free', baseTerritory: 'USA' }));
} else {
const now = currentBasePrice(await c.maybe(`/v1/apps/${appId}/appPriceSchedule/manualPrices?include=appPricePoint&limit=50`));
const now = currentBasePrice(await c.maybe(`/v1/appPriceSchedules/${appId}/manualPrices?include=appPricePoint&limit=50`));
if (now === null) items.push(planItem('price', { attributes: { schedule: 'exists' } }, { schedule: 'exists' }));
else {
const it = planItem('price', { attributes: { price: now } }, { price: 'Free' });

View file

@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { byAppleLocale, confirm, need } from '../lib/ctx';
import { CPP_EDITABLE, CPP_LIMITS, cppCreateBody, cppLocales, type CustomProductPageDef, planCppLocales, reviewItemReq } from '../lib/cpp-events';
import { CPP_EDITABLE, CPP_LIMITS, cppCreateBody, cppDeepLinkReq, cppLocales, type CustomProductPageDef, extraCppLocales, planCppLocales, reviewItemReq } from '../lib/cpp-events';
import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table';
import { numberedImages } from '../play/images';
@ -73,8 +73,10 @@ export async function run(ctx: Ctx): Promise<void> {
if (!page.version || !CPP_EDITABLE.has(page.version.state)) { log(TAG, `"${d.name}": version ${page.version?.state ?? 'missing'}, not editable (a new version: App Store Connect)`); continue; }
const have = (await c.all(`/v1/appCustomProductPageVersions/${page.version.id}/appCustomProductPageLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, promotionalText: l.attributes.promotionalText }));
reqs.push(...planCppLocales(page.version.id, have, locs));
if ((d.deepLink ?? null) !== (page.version.deepLink ?? null) && d.deepLink) reqs.push({ method: 'PATCH', path: `/v1/appCustomProductPageVersions/${page.version.id}`, note: `"${d.name}" deep link`,
body: { data: { type: 'appCustomProductPageVersions', id: page.version.id, attributes: { deepLink: d.deepLink } } } });
const extra = extraCppLocales(have.map(h => h.locale), locs.map(l => l.locale));
if (extra.length) warn(TAG, `"${d.name}": locale(s) ${extra.join(', ')} are on the page but not in the config languages (not removed)`);
const dl = cppDeepLinkReq(page.version.id, d.name, d.deepLink, page.version.deepLink);
if (dl) reqs.push(dl);
}
if (pages.length + reqs.filter(r => r.path === '/v1/appCustomProductPages').length > CPP_LIMITS.pages) throw new Error(`more than ${CPP_LIMITS.pages} custom product pages`);
if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) });

View file

@ -9,7 +9,7 @@
import { existsSync, readFileSync } from 'node:fs';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventCreateBody, planEventLocales, reviewItemReq } from '../lib/cpp-events';
import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventAttrDiff, eventCreateBody, eventPatchReq, planEventLocales, reviewItemReq } from '../lib/cpp-events';
import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table';
import { imageInfo } from '../shots/spec';
@ -59,7 +59,14 @@ export async function run(ctx: Ctx): Promise<void> {
const primary = localeOf(d.primaryLanguage ?? '') ?? want[0]?.locale ?? 'en-US';
const ev = events.find((e: J) => e.attributes.referenceName === d.referenceName);
if (!ev) { created.push({ def: d, index: reqs.length }); reqs.push({ method: 'POST', path: '/v1/appEvents', note: `event "${d.referenceName}" (${d.badge}, ${d.schedule.eventStart} to ${d.schedule.eventEnd})`, body: eventCreateBody(appId, d, primary, d.schedule.territories ?? territories) }); continue; }
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) { log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`); continue; }
const drift = eventAttrDiff(d, ev.attributes);
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) {
if (drift.lines.length) warn(TAG, `"${d.referenceName}" is ${ev.attributes.eventState} and differs from the config: ${drift.lines.join('; ')} (not editable: change it in App Store Connect)`);
else log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`);
continue;
}
const patch = eventPatchReq(ev.id, d.referenceName, drift.attrs, drift.lines);
if (patch) reqs.push(patch);
const have = (await c.all(`/v1/appEvents/${ev.id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, ...l.attributes }));
reqs.push(...planEventLocales(ev.id, have, want));
}

View file

@ -21,6 +21,26 @@ import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'offer-codes';
/** Differences between an existing offer code campaign and the config (Apple does not allow editing a campaign). Pure. */
export function campaignDiff(cur: { eligibility: string[]; prices: { territory: string; pointId?: string }[] },
want: { eligibility: string[]; free: boolean; prices: { territory: string; pointId?: string }[] }): string[] {
const out: string[] = [];
const set = (x: string[]) => [...new Set(x)].sort();
const a = set(cur.eligibility), b = set(want.eligibility);
if (a.join() !== b.join()) out.push(`eligibility ${a.join('+') || '(none)'} -> ${b.join('+') || '(none)'}`);
const curFree = cur.prices.length > 0 && cur.prices.every(x => !x.pointId);
if (cur.prices.length && curFree !== want.free) out.push(`mode ${curFree ? 'free' : 'paid'} -> ${want.free ? 'free' : 'paid'}`);
const ct = set(cur.prices.map(x => x.territory)), wt = set(want.prices.map(x => x.territory));
const gone = ct.filter(t => !wt.includes(t)), added = wt.filter(t => !ct.includes(t));
if (gone.length || added.length) out.push(`territories ${added.length ? `+${added.length} (${added.slice(0, 5).join(',')}${added.length > 5 ? ',...' : ''})` : ''}${gone.length ? ` -${gone.length} (${gone.slice(0, 5).join(',')}${gone.length > 5 ? ',...' : ''})` : ''}`.replace('territories ', 'territories '));
if (!want.free && !curFree) {
const have = new Map(cur.prices.map(x => [x.territory, x.pointId] as const));
const diff = want.prices.filter(x => have.has(x.territory) && have.get(x.territory) !== x.pointId).map(x => x.territory);
if (diff.length) out.push(`prices differ in ${diff.length} territor${diff.length === 1 ? 'y' : 'ies'} (${diff.slice(0, 5).join(',')}${diff.length > 5 ? ',...' : ''})`);
}
return out;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
@ -71,7 +91,13 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${describeReq(req)}: ${made.created ? 'done' : 'exists already'}`);
if (made.created) active++;
}
} else log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`);
} else {
log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`);
const curPrices = (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/prices?include=territory,pricePoint&limit=200`))
.map((x: J) => ({ territory: x.relationships?.territory?.data?.id as string, pointId: x.relationships?.pricePoint?.data?.id as string | undefined })).filter((x: { territory?: string }) => !!x.territory);
const diffs = campaignDiff({ eligibility: cur.attributes.customerEligibilities ?? [], prices: curPrices }, { eligibility: oc.eligibility, free: !!oc.free, prices: await prices(oc) });
if (diffs.length) warn(TAG, `${p.id}: campaign ${oc.name} differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new campaign with another name)`);
}
const haveCustom = new Set(cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode) : []);
for (const cc of oc.customCodes ?? []) if (!haveCustom.has(cc.code)) codeReqs.push({ req: iapCustomCodeReq(id ?? '${new campaign}', cc), product: p.id, campaign: oc.name, oneTime: false });
// one-time batches are not matched one by one: a batch is created when the campaign has fewer batches than configured

View file

@ -103,6 +103,9 @@ export async function run(ctx: Ctx): Promise<void> {
const langOf = byAppleLocale(config.languages);
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
const noLoc = missingVersionLocales(config.languages, locs.map((l: J) => l.attributes.locale as string)).filter(l => !only.length || only.includes(l));
for (const l of noLoc) warn('apple', `${l}: in the config languages but the version has no localization (run apple listing first)`);
const syncSet = (locId: string, sets: J[], type: string, files: string[], tag: string) =>
syncScreenshotSet(c, { rel: 'appStoreVersionLocalization', type: 'appStoreVersionLocalizations', id: locId }, sets, type, files, tag, ctx.args.has('dry'));
@ -168,3 +171,7 @@ async function repairStuck(ctx: Ctx, c: Asc, root: string, locs: J[], src: strin
if (held) log('apple', `${held} screenshot(s) still processing (under 5 min): left alone; run validate again in a few minutes`);
done('apple', total ? `${dry ? 'dry run: would replace' : 'replaced'} ${total} stuck screenshot(s)` : 'no stuck screenshots');
}
/** App Store locales of the config languages that the version has no localization for. Pure. */
export const missingVersionLocales = (langs: { apple?: string }[], have: string[]): string[] =>
[...new Set(langs.flatMap(l => (l.apple ? [l.apple] : [])))].filter(l => !have.includes(l));

View file

@ -8,8 +8,8 @@
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { type TerritoryPoint, validateWinBack, winBackBody } from '../lib/offers';
import { allSubscriptions, applePoints, pickPoint } from '../lib/subscriptions';
import { type TerritoryPoint, validateWinBack, type WinBackDef, winBackBody } from '../lib/offers';
import { allSubscriptions, APPLE_OFFER_MODE, applePoints, pickPoint } from '../lib/subscriptions';
import { textTable } from '../lib/table';
import { pages } from '../prices';
import { ascClient, type J } from './asc';
@ -17,6 +17,17 @@ import { describeReq, execute, type Req } from './jsonapi';
const TAG = 'win-back';
/** Differences between an existing win-back offer and the config (mode, price, dates). No PATCH exists for them. `usaPrice` = the offer's USA customer price (undefined: not read, not compared). Pure. */
export function winBackDiff(have: { offerMode?: string; startDate?: string | null; endDate?: string | null }, usaPrice: number | undefined, want: WinBackDef): string[] {
const out: string[] = [];
const mode = APPLE_OFFER_MODE[want.type];
if (have.offerMode && have.offerMode !== mode) out.push(`mode ${have.offerMode} -> ${mode}`);
if (want.type !== 'free' && usaPrice !== undefined && want.usd !== undefined && Math.abs(usaPrice - want.usd) > 0.005) out.push(`USA price ${usaPrice} -> ${want.usd}`);
if ((have.startDate ?? '').slice(0, 10) !== want.start) out.push(`start ${have.startDate ?? '(none)'} -> ${want.start}`);
if ((have.endDate ?? '').slice(0, 10) !== (want.end ?? '')) out.push(`end ${have.endDate ?? '(none)'} -> ${want.end ?? '(none)'}`);
return out;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
@ -39,6 +50,21 @@ export async function run(ctx: Ctx): Promise<void> {
if (!remote) { skipped.push({ id: s.id, why: 'not on App Store Connect (storebound apple subscriptions)' }); continue; }
const existing = await c.all(`/v1/subscriptions/${remote.id}/winBackOffers?limit=200`);
for (const o of existing) rows.push([s.id, o.attributes.offerId, o.attributes.offerMode, o.attributes.startDate ?? '', o.attributes.endDate ?? '', o.attributes.priority ?? '']);
for (const o of s.winBack!) {
const x = existing.find((e: J) => e.attributes.offerId === o.id);
if (!x) continue;
let usaPrice: number | undefined;
if (o.type !== 'free') {
const pr = await pages(c, `/v1/winBackOffers/${x.id}/prices?include=territory,subscriptionPricePoint&limit=200`);
const usa = pr.data.find((r: J) => r.relationships?.territory?.data?.id === 'USA');
const pid = usa?.relationships?.subscriptionPricePoint?.data?.id;
const inc = pid ? pr.included.find((i: J) => i.id === pid) : undefined;
const cp = Number(inc?.attributes?.customerPrice);
if (Number.isFinite(cp)) usaPrice = cp;
}
const diffs = winBackDiff(x.attributes, usaPrice, o);
if (diffs.length) warn(TAG, `${s.id}: win-back "${o.id}" differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new offer with another id)`);
}
const missing = s.winBack!.filter(o => !existing.some((x: J) => x.attributes.offerId === o.id));
if (!missing.length) continue;
if (remote.attributes.state !== 'APPROVED') { skipped.push({ id: s.id, why: `subscription is ${remote.attributes.state}: win-back offers need an approved subscription` }); continue; }

View file

@ -200,3 +200,41 @@ export const reviewItemReq = (submissionId: string, kind: 'appCustomProductPageV
method: 'POST', path: '/v1/reviewSubmissionItems', note: label,
body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', submissionId), [kind]: one(kind === 'appEvent' ? 'appEvents' : 'appCustomProductPageVersions', id) } } },
});
/** Event attributes that differ from the config: `attrs` = the PATCH attributes (empty when nothing differs), `lines` = readable. Pure. */
export function eventAttrDiff(ev: AppEventDef, now: { badge?: string; priority?: string; purpose?: string; deepLink?: string | null; territorySchedules?: { territories?: string[]; publishStart?: string; eventStart?: string; eventEnd?: string }[] }): { attrs: Record<string, unknown>; lines: string[] } {
const attrs: Record<string, unknown> = {};
const lines: string[] = [];
const same = (a?: string, b?: string) => (a && b ? Date.parse(a) === Date.parse(b) : a === b);
if (now.badge !== undefined && now.badge !== ev.badge) { attrs.badge = ev.badge; lines.push(`badge ${now.badge} -> ${ev.badge}`); }
const pri = (ev.priority ?? 'normal').toUpperCase();
if (now.priority !== undefined && now.priority !== pri) { attrs.priority = pri; lines.push(`priority ${now.priority} -> ${pri}`); }
const purpose = ev.purpose ?? 'APPROPRIATE_FOR_ALL_USERS';
if (now.purpose !== undefined && now.purpose !== purpose) { attrs.purpose = purpose; lines.push(`purpose ${now.purpose} -> ${purpose}`); }
const s = now.territorySchedules?.[0];
const sch: string[] = [];
if (!same(s?.publishStart, ev.schedule.publishStart)) sch.push(`publishStart ${s?.publishStart ?? '(none)'} -> ${ev.schedule.publishStart}`);
if (!same(s?.eventStart, ev.schedule.eventStart)) sch.push(`eventStart ${s?.eventStart ?? '(none)'} -> ${ev.schedule.eventStart}`);
if (!same(s?.eventEnd, ev.schedule.eventEnd)) sch.push(`eventEnd ${s?.eventEnd ?? '(none)'} -> ${ev.schedule.eventEnd}`);
const wt = ev.schedule.territories;
if (wt && [...new Set(s?.territories ?? [])].sort().join() !== [...new Set(wt)].sort().join()) sch.push(`territories ${(s?.territories ?? []).length} -> ${wt.length}`);
if (sch.length) {
attrs.territorySchedules = [{ territories: wt ?? s?.territories ?? [], publishStart: ev.schedule.publishStart, eventStart: ev.schedule.eventStart, eventEnd: ev.schedule.eventEnd }];
lines.push(...sch);
}
return { attrs, lines };
}
/** PATCH /v1/appEvents/{id} with the changed attributes, null when none. */
export const eventPatchReq = (eventId: string, name: string, attrs: Record<string, unknown>, lines: string[]): Req | null =>
Object.keys(attrs).length ? { method: 'PATCH', path: `/v1/appEvents/${eventId}`, note: `event "${name}": ${lines.join('; ')}`, body: { data: { type: 'appEvents', id: eventId, attributes: attrs } } } : null;
/** PATCH of a page version's deep link: set, change, or clear (config has none, the page has one). Null when equal. Pure. */
export function cppDeepLinkReq(versionId: string, name: string, want: string | undefined, have: string | null | undefined): Req | null {
if ((want ?? null) === (have ?? null)) return null;
return { method: 'PATCH', path: `/v1/appCustomProductPageVersions/${versionId}`, note: want ? `"${name}" deep link` : `"${name}" deep link removed`,
body: { data: { type: 'appCustomProductPageVersions', id: versionId, attributes: { deepLink: want ?? null } } } };
}
/** Locales on a page version that the config has no language for. Pure. */
export const extraCppLocales = (have: string[], want: string[]): string[] => have.filter(l => !want.includes(l));

View file

@ -50,6 +50,10 @@ export function changeLines(changes: DataSafetyChange[]): string[] {
return [...(acc.length ? [' account and deletion answers:', ...acc.map(line)] : []), ...(rest.length ? [' data types and other answers:', ...rest.map(line)] : [])];
}
/** The dry-run line about the state it compares with. Pure. */
export const dataSafetyDryNote = (last: { at: string } | null): string =>
`Play has no read API for the Data safety form: this compares with the last upload from this project (${DATA_SAFETY_HASH} sha256${last ? `, uploaded ${last.at.slice(0, 16).replace('T', ' ')} UTC` : ', none recorded'}), not with the live form; --force re-sends the CSV`;
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const ds = need(config.dataSafety, 'dataSafety');
@ -69,6 +73,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (ctx.args.has('dry')) {
const upFile = join(root, DATA_SAFETY_UPLOADED);
const before = existsSync(upFile) ? readFileSync(upFile, 'utf8') : null;
log('play', dataSafetyDryNote(last));
const changes = dataSafetyDiff(before, rows);
log('play', `changes against ${before ? `the last uploaded CSV (${DATA_SAFETY_UPLOADED}; Play cannot read the form back)` : 'nothing (no upload recorded: every answer is new)'}: ${changes.length} answer(s)`);
for (const l of changeLines(changes)) console.log(l);

View file

@ -171,7 +171,7 @@ export async function run(ctx: Ctx): Promise<void> {
// a language Play does not have yet: its edit only commits with a hi-res icon (pitfall 102)
if (!now.some(n => n.language === b.language)) {
const graphics = firstGraphics(ctx, b.language!);
if (!graphics.some(([t]) => t === 'icon')) throw new Error(`${b.language}: a new Play language needs the 512x512 icon in the same edit (play.icon or store/icons/play-512.png): storebound icons makes it`);
if (!graphics.some(([t]) => t === 'icon')) warn('play', `${b.language}: no 512x512 icon to send with this new language (play.icon or store/icons/play-512.png; storebound icons makes it): Play may refuse the commit with 403 "no high resolution icon"`);
for (const [type, f] of graphics) {
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
log('play', `${b.language}: ${type} uploaded with the new listing`);

View file

@ -23,6 +23,12 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
'inAppPurchases/offerCodes': { type: 'inAppPurchaseOfferCodes' },
'inAppPurchaseOfferCodes/customCodes': { type: 'inAppPurchaseOfferCodeCustomCodes' },
'inAppPurchaseOfferCodes/oneTimeUseCodes': { type: 'inAppPurchaseOfferCodeOneTimeUseCodes' },
'inAppPurchaseOfferCodes/prices': { type: 'inAppPurchaseOfferPrices' },
'inAppPurchasePriceSchedules/manualPrices': { type: 'inAppPurchasePrices' },
'inAppPurchasePriceSchedules/baseTerritory': { type: 'territories', one: true },
'appPriceSchedules/manualPrices': { type: 'appPrices' },
'inAppPurchaseAvailabilities/availableTerritories': { type: 'territories' },
'winBackOffers/prices': { type: 'winBackOfferPrices' },
'apps/appStoreVersions': { type: 'appStoreVersions' },
'appStoreVersions/appStoreVersionLocalizations': { type: 'appStoreVersionLocalizations' },
'appStoreVersions/appStoreReviewDetail': { type: 'appStoreReviewDetails', one: true },
@ -259,6 +265,11 @@ export class FakeAsc {
const self = this.res.get(id);
if (!rel) return self && self.type === type ? json({ data: this.view(self) }) : json({ errors: [{ status: '404' }] }, 404);
if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) });
// an availability's territories are the ids its create named (a to-many relationship, not child resources)
if (rel === 'availableTerritories' && self) {
const ids = self.rels.availableTerritories;
return json({ data: (Array.isArray(ids) ? ids : ids ? [ids] : []).map(t => ({ type: 'territories', id: t })), links: {} });
}
const l = LISTS[`${type}/${rel}`];
if (!l) return json({ errors: [{ status: '599', detail: `fake: no list ${type}/${rel}` }] }, 599);
const kids = this.filtered(this.children(id, l.type), u.searchParams);

74
tests/misc-drift.test.ts Normal file
View file

@ -0,0 +1,74 @@
import { describe, expect, test } from 'bun:test';
import { campaignDiff } from '../cli/apple/offer-codes';
import { winBackDiff } from '../cli/apple/win-back';
import { cppDeepLinkReq, type AppEventDef, eventAttrDiff, eventPatchReq, extraCppLocales } from '../cli/lib/cpp-events';
import { missingVersionLocales } from '../cli/apple/screenshots';
import { dataSafetyDryNote } from '../cli/play/data-safety';
describe('offer code campaign drift', () => {
const cur = { eligibility: ['NON_SPENDER'], prices: [{ territory: 'USA', pointId: 'p1' }, { territory: 'DEU', pointId: 'p2' }] };
test('equal: no differences', () => {
expect(campaignDiff(cur, { eligibility: ['NON_SPENDER'], free: false, prices: cur.prices })).toEqual([]);
});
test('eligibility, territories, price and mode differ', () => {
const d = campaignDiff(cur, { eligibility: ['NON_SPENDER', 'CHURNED_SPENDER'], free: false, prices: [{ territory: 'USA', pointId: 'p9' }, { territory: 'FRA', pointId: 'p3' }] });
expect(d.join('|')).toContain('eligibility');
expect(d.join('|')).toContain('+1 (FRA)');
expect(d.join('|')).toContain('-1 (DEU)');
expect(d.join('|')).toContain('prices differ in 1 territory');
expect(campaignDiff(cur, { eligibility: ['NON_SPENDER'], free: true, prices: cur.prices }).join()).toContain('mode paid -> free');
});
});
describe('win-back drift', () => {
const want = { id: 'wb', type: 'pay-as-you-go', usd: 2.99, paidMonths: 3, lapsedMonths: { min: 1, max: 12 }, start: '2027-01-01', end: '2027-02-01' } as const;
test('equal', () => expect(winBackDiff({ offerMode: 'PAY_AS_YOU_GO', startDate: '2027-01-01', endDate: '2027-02-01' }, 2.99, want)).toEqual([]));
test('mode, price, dates', () => {
const d = winBackDiff({ offerMode: 'FREE_TRIAL', startDate: '2027-01-05', endDate: null }, 1.99, want);
expect(d.length).toBe(4);
expect(winBackDiff({ offerMode: 'PAY_AS_YOU_GO', startDate: '2027-01-01', endDate: '2027-02-01' }, undefined, want)).toEqual([]);
});
});
describe('event attribute drift', () => {
const ev: AppEventDef = { referenceName: 'Spring', badge: 'CHALLENGE', priority: 'high', schedule: { publishStart: '2027-03-01T00:00:00Z', eventStart: '2027-03-05T00:00:00Z', eventEnd: '2027-03-10T00:00:00Z' }, listings: {} };
const now = { badge: 'CHALLENGE', priority: 'HIGH', purpose: 'APPROPRIATE_FOR_ALL_USERS', territorySchedules: [{ territories: ['USA'], publishStart: '2027-03-01T00:00:00.000Z', eventStart: '2027-03-05T00:00:00.000Z', eventEnd: '2027-03-10T00:00:00.000Z' }] };
test('same instants in another format: no drift', () => {
const d = eventAttrDiff(ev, now);
expect(d.lines).toEqual([]);
expect(eventPatchReq('e1', 'Spring', d.attrs, d.lines)).toBeNull();
});
test('badge, priority and end differ: one PATCH', () => {
const d = eventAttrDiff({ ...ev, badge: 'MAJOR_UPDATE', priority: 'normal', schedule: { ...ev.schedule, eventEnd: '2027-03-12T00:00:00Z' } }, now);
expect(d.lines.length).toBe(3);
const r = eventPatchReq('e1', 'Spring', d.attrs, d.lines)!;
expect(r.method).toBe('PATCH');
expect(r.path).toBe('/v1/appEvents/e1');
expect((r.body as { data: { attributes: Record<string, unknown> } }).data.attributes.territorySchedules).toHaveLength(1);
});
});
describe('custom product page drift', () => {
test('deep link removed is cleared', () => {
const r = cppDeepLinkReq('v1', 'Page', undefined, 'example://old')!;
expect(r.note).toContain('removed');
expect((r.body as { data: { attributes: { deepLink: unknown } } }).data.attributes.deepLink).toBeNull();
});
test('equal or both empty: nothing', () => {
expect(cppDeepLinkReq('v1', 'Page', 'example://a', 'example://a')).toBeNull();
expect(cppDeepLinkReq('v1', 'Page', undefined, null)).toBeNull();
});
test('extra locales on the page', () => expect(extraCppLocales(['en-US', 'fr-FR'], ['en-US'])).toEqual(['fr-FR']));
});
describe('screenshots and data safety notes', () => {
test('version without a localization for a config language', () => {
expect(missingVersionLocales([{ apple: 'en-US' }, { apple: 'tr' }, {}], ['en-US'])).toEqual(['tr']);
});
test('data safety dry note says Play cannot be read and --force re-sends', () => {
const n = dataSafetyDryNote({ at: '2027-01-01T10:00:00Z' });
expect(n).toContain('no read API');
expect(n).toContain('--force');
expect(dataSafetyDryNote(null)).toContain('none recorded');
});
});