From e381bc2256f700346fa1fb28cc5e7a08899a2035 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:38:54 +0800 Subject: [PATCH] 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 --- cli/apple/appinfo.ts | 2 +- cli/apple/cpp.ts | 8 ++-- cli/apple/events.ts | 11 +++++- cli/apple/offer-codes.ts | 28 +++++++++++++- cli/apple/screenshots.ts | 7 ++++ cli/apple/win-back.ts | 30 ++++++++++++++- cli/lib/cpp-events.ts | 38 ++++++++++++++++++ cli/play/data-safety.ts | 5 +++ cli/play/listing.ts | 2 +- tests/helpers/fake-stores.ts | 11 ++++++ tests/misc-drift.test.ts | 74 ++++++++++++++++++++++++++++++++++++ 11 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 tests/misc-drift.test.ts diff --git a/cli/apple/appinfo.ts b/cli/apple/appinfo.ts index d99b83b..9dc2360 100644 --- a/cli/apple/appinfo.ts +++ b/cli/apple/appinfo.ts @@ -98,7 +98,7 @@ export async function planAppInfo(ctx: Ctx, c: Awaited { 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)) }); diff --git a/cli/apple/events.ts b/cli/apple/events.ts index 6bde356..5691256 100644 --- a/cli/apple/events.ts +++ b/cli/apple/events.ts @@ -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 { 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)); } diff --git a/cli/apple/offer-codes.ts b/cli/apple/offer-codes.ts index ce300f3..30ccc4d 100644 --- a/cli/apple/offer-codes.ts +++ b/cli/apple/offer-codes.ts @@ -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 { const { config, root, resolve } = ctx.loaded; const a = ctx.args; @@ -71,7 +91,13 @@ export async function run(ctx: Ctx): Promise { 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 diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index afcc0d5..fbb353f 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -103,6 +103,9 @@ export async function run(ctx: Ctx): Promise { 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)); diff --git a/cli/apple/win-back.ts b/cli/apple/win-back.ts index 9631dda..2ef5836 100644 --- a/cli/apple/win-back.ts +++ b/cli/apple/win-back.ts @@ -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 { const { config, root } = ctx.loaded; const a = ctx.args; @@ -39,6 +50,21 @@ export async function run(ctx: Ctx): Promise { 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; } diff --git a/cli/lib/cpp-events.ts b/cli/lib/cpp-events.ts index e85e6ad..630fca9 100644 --- a/cli/lib/cpp-events.ts +++ b/cli/lib/cpp-events.ts @@ -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; lines: string[] } { + const attrs: Record = {}; + 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, 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)); diff --git a/cli/play/data-safety.ts b/cli/play/data-safety.ts index 7610e43..c95eef3 100644 --- a/cli/play/data-safety.ts +++ b/cli/play/data-safety.ts @@ -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 { const { config, root, resolve } = ctx.loaded; const ds = need(config.dataSafety, 'dataSafety'); @@ -69,6 +73,7 @@ export async function run(ctx: Ctx): Promise { 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); diff --git a/cli/play/listing.ts b/cli/play/listing.ts index fc0ded4..f0dce56 100644 --- a/cli/play/listing.ts +++ b/cli/play/listing.ts @@ -171,7 +171,7 @@ export async function run(ctx: Ctx): Promise { // 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`); diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 0a1e00d..756ebfc 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -23,6 +23,12 @@ const LISTS: Record = { '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); diff --git a/tests/misc-drift.test.ts b/tests/misc-drift.test.ts new file mode 100644 index 0000000..82cfe30 --- /dev/null +++ b/tests/misc-drift.test.ts @@ -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 } }).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'); + }); +});