// One app card per product across studios and store accounts: folder matching, automatic pairing (same id, close id, // same name), manual pair / unpair, links, studio moves, the ignore list, and the portfolio filter / sort / grouping. import { describe, expect, test } from 'bun:test'; import type { Account, Project, ProjectSummary, StoreApp } from '../src/api'; import { buildApps, filterSort, groupByStudio, type ListRow, NO_OVERRIDES, normId, normName, type Overrides, pairCandidates, storeTarget, studioCounts, withIgnored, withLink, withPair, withStudio, withUnpair, } from '../src/lib/identity'; import { crashCrossed, newLowReviews, releaseChanges } from '../src/lib/notify'; const P1 = '0a1b2c3d4e5f6071', A1 = '2c3d4e5f60718293', A2 = '3d4e5f6071829304'; const acc = (id: string, kind: 'play' | 'apple', studio: string | null): Account => ({ id, label: id, kind, created: '2026-10-01T00:00:00Z', studio }); const ios = (id: string, bundleId: string, name: string): StoreApp => ({ store: 'ios', id, bundleId, name, platforms: ['IOS'], icon: null }); const droid = (pkg: string, name: string): StoreApp => ({ store: 'android', id: pkg, bundleId: pkg, name, platforms: ['ANDROID'], icon: null }); const summary = (bundleId: string | null, pkg: string | null, appId: string | null = null): ProjectSummary => ({ dir: '/x', config: '/x/c', name: 'x', languages: ['en'], apple: bundleId ? { bundleId, appId } : null, play: pkg ? { packageName: pkg } : null, platforms: [], products: 0, productIds: [], subscriptions: 0, subscriptionIds: [], catalog: { ios: [], android: [] }, shots: false, testflightGroups: 0, }); const proj = (id: string, name: string, play: string | null, apple: string | null): Project => ({ id, path: `/apps/${id}`, name, playAccount: play, appleAccount: apple, added: '2026-10-01T00:00:00Z' }); const accounts = [acc(P1, 'play', 's1'), acc(A1, 'apple', 's1'), acc(A2, 'apple', 's2')]; const storeApps = [ { account: P1, apps: [droid('com.example.prism', 'Prism'), droid('com.example.orbit', 'Orbit Hop'), droid('com.example.block', 'Block Garden'), droid('com.example.lantern.words', 'Word Lantern'), droid('com.example.tycoon', 'Pocket Tycoon')] }, { account: A1, apps: [ios('101', 'com.example.prism', 'Prism'), ios('102', 'com.example.orbit', 'Orbit Hop'), ios('103', 'com.example.Block.ios', 'Block Garden HD'), ios('104', 'com.example.wordlantern', 'Word Lantern')] }, { account: A2, apps: [ios('201', 'com.example.habit', 'Habit Hatch')] }, ]; const prism = proj('p1', 'Prism Puddle', P1, A1); const build = (o: Overrides = NO_OVERRIDES, projects = [prism]) => buildApps({ projects, accounts, storeApps, overrides: o, summaryOf: p => (p.id === 'p1' ? summary('com.example.prism', 'com.example.prism') : summary(null, null)) }); const byName = (apps: ReturnType, n: string) => apps.find(a => a.name === n)!; describe('identity pairing', () => { test('a folder takes the store apps its config names; the rest pair by id, close id, then name', () => { const apps = build(); expect(apps.map(a => [a.name, a.pairing, !!a.ios, !!a.android])).toEqual([ ['Prism Puddle', 'folder', true, true], ['Orbit Hop', 'id', true, true], ['Block Garden HD', 'close', true, true], ['Word Lantern', 'name', true, true], ['Pocket Tycoon', 'single', false, true], ['Habit Hatch', 'single', true, false], ]); expect(byName(apps, 'Prism Puddle')).toMatchObject({ key: 'p1', target: 'p1', studio: 's1', ios: { key: `ios:${A1}:101` }, android: { key: `android:${P1}:com.example.prism` } }); const orbit = byName(apps, 'Orbit Hop'); expect(orbit.target).toBe(`store:ios:${A1}:102:com.example.orbit;android:${P1}:com.example.orbit`); expect(orbit.key).toBe(orbit.target); expect(orbit.ignoreKeys).toEqual([`app:ios:${A1}:102`, `app:android:${P1}:com.example.orbit`]); expect(byName(apps, 'Habit Hatch').studio).toBe('s2'); }); test('the app id in the config wins over the bundle id; accounts mapped to the folder are preferred', () => { const twin = [...storeApps, { account: A2, apps: [ios('999', 'com.example.prism', 'Prism (old team)')] }]; const apps = buildApps({ projects: [prism], accounts, storeApps: twin, overrides: NO_OVERRIDES, summaryOf: () => summary('com.example.prism', 'com.example.prism', '999') }); expect(apps[0]!.ios?.app.id).toBe('999'); const apps2 = buildApps({ projects: [prism], accounts, storeApps: twin, overrides: NO_OVERRIDES, summaryOf: () => summary('com.example.prism', 'com.example.prism') }); expect(apps2[0]!.ios?.account).toBe(A1); }); test('unpair blocks the automatic pair; manual pair beats it', () => { const o1 = withUnpair(NO_OVERRIDES, `ios:${A1}:102`, `android:${P1}:com.example.orbit`); const apps1 = build(o1); expect(apps1.filter(a => a.name === 'Orbit Hop').map(a => [!!a.ios, !!a.android, a.pairing])).toEqual([[false, true, 'single'], [true, false, 'single']]); const o2 = withPair(o1, `ios:${A1}:102`, `android:${P1}:com.example.tycoon`); expect(o2.unpairs).toHaveLength(1); const apps2 = build(o2); const manual = apps2.find(a => a.pairing === 'manual')!; expect([manual.ios?.app.id, manual.android?.app.id]).toEqual(['102', 'com.example.tycoon']); // pairing the unpaired two again drops the unpair expect(withPair(o1, `ios:${A1}:102`, `android:${P1}:com.example.orbit`).unpairs).toEqual([]); }); test('links: a store-only app joins a folder by hand', () => { const orbit = byName(build(), 'Orbit Hop'); const fresh = proj('p2', 'Orbit (folder)', null, null); const apps = build(withLink(NO_OVERRIDES, orbit, 'p2'), [prism, fresh]); const linked = apps.find(a => a.key === 'p2')!; expect([linked.ios?.app.id, linked.android?.app.id, linked.pairing]).toEqual(['102', 'com.example.orbit', 'folder']); expect(apps.some(a => a.name === 'Orbit Hop' && !a.project)).toBe(false); }); test('ignore: an app (all its store keys) or a whole account; studio moves', () => { const tycoon = byName(build(), 'Pocket Tycoon'); const o = withIgnored(NO_OVERRIDES, tycoon.ignoreKeys, true); expect(byName(build(o), 'Pocket Tycoon').ignored).toBe(true); expect(withIgnored(o, tycoon.ignoreKeys, false).ignored).toEqual([]); const noA1 = build(withIgnored(NO_OVERRIDES, [`account:${A1}`], true)); expect(noA1.some(a => a.ios?.account === A1)).toBe(false); expect(byName(noA1, 'Orbit Hop').ios).toBeNull(); const moved = build(withStudio(NO_OVERRIDES, tycoon, 's2')); expect(byName(moved, 'Pocket Tycoon').studio).toBe('s2'); const back = withStudio(withStudio(NO_OVERRIDES, tycoon, 's2'), tycoon, null); expect(back.appStudio).toEqual([]); expect(withStudio(NO_OVERRIDES, byName(build(), 'Prism Puddle'), 's9').appStudio).toEqual([{ key: 'project:p1', studio: 's9' }]); expect(studioCounts(build(o))).toEqual({ s1: 4, s2: 1 }); }); test('accounts that are gone or never read add nothing', () => { const apps = buildApps({ projects: [], accounts: [acc(P1, 'play', null)], storeApps: [{ account: 'deadbeefdeadbeef', apps: [droid('com.example.x', 'X')] }], overrides: NO_OVERRIDES, summaryOf: () => undefined }); expect(apps).toEqual([]); }); test('helpers: normalised ids and names, store targets, pair candidates', () => { expect(normId('com.Example.Block.ios')).toBe(normId('com.example.block')); expect(normId('com.example.block-android')).toBe('comexampleblock'); expect(normName('Word Lantern!')).toBe(normName('word-lantern')); expect(storeTarget(null, { key: 'k', account: P1, store: 'android', app: droid('com.a.b', 'B') })).toBe(`store:android:${P1}:com.a.b`); const apps = build(withUnpair(NO_OVERRIDES, `ios:${A1}:102`, `android:${P1}:com.example.orbit`)); const lone = apps.find(a => a.name === 'Orbit Hop' && a.ios)!; expect(pairCandidates(lone, apps)[0]!.android?.app.id).toBe('com.example.orbit'); expect(pairCandidates(byName(apps, 'Prism Puddle'), apps)).toEqual([]); }); }); describe('portfolio list', () => { const apps = build(); const rows: ListRow[] = apps.map((app, i) => ({ app, attention: [0, 3, 1, 0, 2, 0][i]!, blockers: [0, 1, 0, 0, 0, 0][i]!, updated: [`2026-10-0${i + 1}`, null, '2026-10-09', null, '2026-09-01', '2026-10-05'][i]! })); test('filter by studio and search ids, sort by attention / recent / name', () => { expect(filterSort(rows, { studio: 'all', query: '', sort: 'attention' }).map(r => r.app.name).slice(0, 3)).toEqual(['Orbit Hop', 'Pocket Tycoon', 'Block Garden HD']); expect(filterSort(rows, { studio: 'all', query: '', sort: 'recent' }).map(r => r.app.name)[0]).toBe('Block Garden HD'); expect(filterSort(rows, { studio: 's2', query: '', sort: 'name' }).map(r => r.app.name)).toEqual(['Habit Hatch']); expect(filterSort(rows, { studio: 'all', query: 'lantern.words', sort: 'name' }).map(r => r.app.name)).toEqual(['Word Lantern']); expect(filterSort(rows, { studio: 'none', query: '', sort: 'name' })).toEqual([]); }); test('groups follow the studios\' order, no studio last; ignored apps never listed', () => { const g = groupByStudio(rows, [{ id: 's2' }, { id: 's1' }]); expect(g.map(x => [x.studio, x.rows.length])).toEqual([['s2', 1], ['s1', 5]]); const ignored = rows.map(r => (r.app.name === 'Habit Hatch' ? { ...r, app: { ...r.app, ignored: true } } : r)); expect(filterSort(ignored, { studio: 'all', query: '', sort: 'name' }).some(r => r.app.name === 'Habit Hatch')).toBe(false); }); }); describe('notifications', () => { const status = (phase: string) => ({ apps: [{ project: '/a', ok: true, app: { id: '/a', name: 'A', platforms: { ios: null, android: null } }, summary: null, check: null, catalog: null, storeErrors: {}, errors: [], release: { source: 'live' as const, current: null, next: null, all: [{ store: 'ios' as const, version: '2.0', build: '1', state: phase, phase: phase as 'review', fraction: null, track: null }] } }], at: 'x' }); test('release phase changes, only against a previous read', () => { expect(releaseChanges(null, status('review'))).toEqual([]); expect(releaseChanges(status('review'), status('review'))).toEqual([]); expect(releaseChanges(status('review'), status('approved'))).toEqual([{ project: '/a', name: 'A', store: 'ios', version: '2.0', from: 'review', to: 'approved', kind: 'approved' }]); expect(releaseChanges(status('draft'), status('testing'))).toEqual([]); }); test('new 1-2 star reviews and threshold crossings', () => { const r = (id: string, rating: number) => ({ store: 'play' as const, id, rating, body: '', date: '2026-10-01' }); expect(newLowReviews(null, [r('a', 1)])).toEqual([]); expect(newLowReviews([r('a', 1)], [r('a', 1), r('b', 2), r('c', 5)]).map(x => x.id)).toEqual(['b']); const h = (crash: number) => ({ date: 'x', play: { window: { start: '', end: '', days: 28 }, anr: [], issues: [], crash: [{ date: '2026-10-01', dims: {}, values: { userPerceivedCrashRate: crash } }] } }); expect(crashCrossed(h(0.005), h(0.02))).toEqual(['crash']); expect(crashCrossed(h(0.02), h(0.03))).toEqual([]); expect(crashCrossed(null, h(0.03))).toEqual([]); }); });