import { describe, expect, test } from 'bun:test'; import { describeReq } from '../cli/apple/jsonapi'; import { parseCsv, parseCsvRecords, toCsv } from '../cli/lib/csv'; import { validateConfig } from '../cli/lib/config'; import { betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, type Group, groupFromApi, inviteReq, maskEmail, parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, validateTestflight, whatToTestFromListing, } from '../cli/lib/testflight'; import { TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; const G: Group[] = [ { id: 'g-int', name: 'Team', internal: true, hasAccessToAllBuilds: false }, { id: 'g-all', name: 'Everyone inside', internal: true, hasAccessToAllBuilds: true }, { id: 'g-ext', name: 'Friends', internal: false }, ]; describe('csv', () => { test('quotes, doubled quotes, CRLF, BOM, empty lines', () => { expect(parseCsv('a,b\r\n"x, y","say ""hi"""\r\n\r\nz,\n')).toEqual([['a', 'b'], ['x, y', 'say "hi"'], ['z', '']]); expect(parseCsv('a\tb\n1\t2', '\t')).toEqual([['a', 'b'], ['1', '2']]); expect(parseCsvRecords('Email, First Name\nA@example.com ,Jo')).toEqual([{ email: 'A@example.com', first_name: 'Jo' }]); expect(toCsv([['a', 'b,c'], [1, 'say "x"']])).toBe('a,"b,c"\n1,"say ""x"""\n'); }); }); describe('testflight config', () => { test('groups and the tester CSV location', () => { expect(validateTestflight({ groups: [{ name: 'Team', internal: true, allBuilds: true }, { name: 'Friends', publicLink: true, publicLinkLimit: 100 }], testers: '.storebound/testers.csv' })).toEqual([]); const e = validateTestflight({ groups: [{ name: 'A', internal: true, publicLink: true }, { name: 'A', allBuilds: true }, { name: 'B', publicLinkLimit: 0 }], testers: 'store/testers.csv' }); expect(e.join('\n')).toContain('public links are for external groups only'); expect(e.join('\n')).toContain('duplicate "A"'); expect(e.join('\n')).toContain('allBuilds: internal groups only'); expect(e.join('\n')).toContain('publicLinkLimit: 1-10000'); expect(e.join('\n')).toContain('keep the tester CSV in .storebound/'); const base = { app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }, languages: [{ id: 'en', apple: 'en-US' }] }; expect(validateConfig({ ...base, apple: { bundleId: 'com.example.game', testflight: { testers: 'testers.csv' } } }).join()).toContain('.storebound/'); }); }); describe('testflight groups', () => { test('creates only missing groups, with the right attributes', () => { const p = planGroups('app1', G, [{ name: 'Team', internal: true }, { name: 'QA', internal: true, allBuilds: true }, { name: 'Public', publicLink: true, publicLinkLimit: 500, feedback: false }]); expect(p.existing).toEqual(['Team']); expect(p.create).toHaveLength(2); expect(p.create[0]!.body).toEqual({ data: { type: 'betaGroups', attributes: { name: 'QA', isInternalGroup: true, hasAccessToAllBuilds: true, feedbackEnabled: true }, relationships: { app: { data: { type: 'apps', id: 'app1' } } } } }); expect((p.create[1]!.body as { data: { attributes: unknown } }).data.attributes).toEqual({ name: 'Public', publicLinkEnabled: true, publicLinkLimitEnabled: true, publicLinkLimit: 500, feedbackEnabled: false }); expect(groupFromApi({ id: 'x', attributes: { name: 'N', isInternalGroup: true, hasAccessToAllBuilds: true } })).toMatchObject({ id: 'x', name: 'N', internal: true, hasAccessToAllBuilds: true }); }); }); describe('testflight testers', () => { const csv = 'email,first_name,last_name,groups\nJane@Example.com,Jane,Doe,Friends\nbob@example.com,,,Friends;Team\nnew@example.com,New,,\n'; test('parse: lower-cases emails, default group, rejects bad rows without leaking emails', () => { const t = parseTesters(csv, 'Friends'); expect(t.map(x => [x.email, x.groups])).toEqual([['jane@example.com', ['Friends']], ['bob@example.com', ['Friends', 'Team']], ['new@example.com', ['Friends']]]); expect(() => parseTesters('email\nnot-an-email\n')).toThrow('is not an email'); try { parseTesters('email\nsecret.person@example.com\nsecret.person@example.com\n'); } catch (e) { expect(String(e)).toContain('s***@example.com listed twice'); expect(String(e)).not.toContain('secret.person'); } expect(maskEmail('jane.doe@example.com')).toBe('j***@example.com'); }); test('plan: create new, add existing to more groups, remove only with --remove and only from managed groups', () => { const want = parseTesters(csv, 'Friends'); const existing = new Map([ ['jane@example.com', { id: 't-jane', groupIds: ['g-ext'] }], ['bob@example.com', { id: 't-bob', groupIds: ['g-ext'] }], ['old@example.com', { id: 't-old', groupIds: ['g-ext', 'g-all'] }], ]); const p = planTesters(G, existing, want); expect(p.create).toHaveLength(1); expect(p.create[0]!.body).toEqual({ data: { type: 'betaTesters', attributes: { email: 'new@example.com', firstName: 'New' }, relationships: { betaGroups: { data: [{ type: 'betaGroups', id: 'g-ext' }] } } } }); expect(p.add).toEqual([{ method: 'POST', path: '/v1/betaGroups/g-int/relationships/betaTesters', body: { data: [{ type: 'betaTesters', id: 't-bob' }] }, note: '1 existing tester(s) -> "Team"' }]); expect(p.remove).toEqual([]); const r = planTesters(G, existing, want, { remove: true }); expect(r.remove).toEqual([{ method: 'DELETE', path: '/v1/betaGroups/g-ext/relationships/betaTesters', body: { data: [{ type: 'betaTesters', id: 't-old' }] }, note: '1 tester(s) leave "Friends"' }]); expect(r.summary).toEqual(['1 new tester(s)', '1 added to groups', '1 removed from groups']); expect(planTesters(G, existing, want, { only: ['Nope'] }).unknownGroups).toEqual(['Nope']); expect(describeReq(p.create[0]!)).not.toContain('new@example.com'); expect(describeReq(p.create[0]!)).toContain('n***@example.com'); }); test('invitation request', () => { expect(inviteReq('app1', 't1', 'x').body).toEqual({ data: { type: 'betaTesterInvitations', relationships: { app: { data: { type: 'apps', id: 'app1' } }, betaTester: { data: { type: 'betaTesters', id: 't1' } } } } }); }); }); describe('testflight builds', () => { const resp = { data: [ { id: 'b1', attributes: { version: '42', processingState: 'VALID' }, relationships: { preReleaseVersion: { data: { id: 'p1' } } } }, { id: 'b2', attributes: { version: '42', processingState: 'VALID' }, relationships: { preReleaseVersion: { data: { id: 'p2' } } } }, { id: 'b3', attributes: { version: '43', processingState: 'PROCESSING' }, relationships: { preReleaseVersion: { data: { id: 'p2' } } } }, ], included: [{ type: 'preReleaseVersions', id: 'p1', attributes: { version: '1.0.0' } }, { type: 'preReleaseVersions', id: 'p2', attributes: { version: '1.1.0' } }], }; test('pick by explicit number, ambiguity needs --version', () => { const builds = buildsWithVersion(resp); expect(pickBuild(builds, '43').id).toBe('b3'); expect(pickBuild(builds, '42', '1.1.0').id).toBe('b2'); expect(() => pickBuild(builds, '42')).toThrow('add --version'); expect(() => pickBuild(builds, '99')).toThrow('no build 99'); }); test('readiness', () => { expect(betaReadiness({ processingState: 'VALID', usesNonExemptEncryption: false }, { internalBuildState: 'READY_FOR_BETA_TESTING' })).toEqual([]); expect(betaReadiness({ processingState: 'PROCESSING' }, null)[0]).toContain('PROCESSING'); expect(betaReadiness({ processingState: 'VALID', expired: true }, null)[0]).toContain('expired'); expect(betaReadiness({ processingState: 'VALID', usesNonExemptEncryption: null }, { internalBuildState: 'MISSING_EXPORT_COMPLIANCE' })[0]).toContain('export compliance'); }); test('distribute skips groups that have it and internal all-builds groups', () => { expect(planDistribute('b1', G, ['g-ext']).map(r => r.path)).toEqual(['/v1/betaGroups/g-int/relationships/builds']); expect(planDistribute('b1', G, [])[0]!.body).toEqual({ data: [{ type: 'builds', id: 'b1' }] }); expect(betaReviewReq('b1').body).toEqual({ data: { type: 'betaAppReviewSubmissions', relationships: { build: { data: { type: 'builds', id: 'b1' } } } } }); }); }); describe('testflight what to test and info', () => { const md = '# App Store\n\n## en-US · English\n\n### whatsNew\n```\nNew: desert world.\n```\n\n### whatToTest\n```\nPlay level 3 twice.\n```\n\n## tr · Türkçe\n\n### whatsNew\n```\nYeni: çöl dünyası.\n```\n\n## Notes\n\ntext\n'; test('listing -> per locale text, whatToTest wins over whatsNew', () => { expect(whatToTestFromListing(md)).toEqual({ 'en-US': 'Play level 3 twice.', tr: 'Yeni: çöl dünyası.' }); }); test('create missing, patch changed, leave equal, refuse over the limit', () => { const reqs = planWhatToTest('b1', [{ id: 'l1', locale: 'en-US', whatsNew: 'old' }, { id: 'l2', locale: 'de-DE', whatsNew: 'x' }, { id: 'l3', locale: 'ja', whatsNew: 'same' }], { 'en-US': 'new', tr: 'yeni', ja: 'same' }); expect(reqs.map(r => `${r.method} ${r.path}`)).toEqual(['PATCH /v1/betaBuildLocalizations/l1', 'POST /v1/betaBuildLocalizations']); expect(reqs[1]!.body).toEqual({ data: { type: 'betaBuildLocalizations', attributes: { locale: 'tr', whatsNew: 'yeni' }, relationships: { build: { data: { type: 'builds', id: 'b1' } } } } }); expect(() => planWhatToTest('b1', [], { en: 'x'.repeat(4001) })).toThrow('over 4000'); }); test('test information and beta review details', () => { const want = { 'en-US': { description: 'A puzzle game.', feedbackEmail: 'support@example.com', privacyPolicyUrl: 'https://example.com/p' }, tr: { description: 'Bulmaca.' } }; const r = planBetaInfo('app1', [{ id: 'bl1', locale: 'en-US', attributes: { description: 'A puzzle game.', feedbackEmail: 'old@example.com' } }], want); expect(r.map(x => `${x.method} ${x.path}`)).toEqual(['PATCH /v1/betaAppLocalizations/bl1', 'POST /v1/betaAppLocalizations']); expect((r[0]!.body as { data: { attributes: unknown } }).data.attributes).toEqual({ feedbackEmail: 'support@example.com', privacyPolicyUrl: 'https://example.com/p' }); expect(betaDescriptions({ description: { en: 'Hi' } }, [{ id: 'en', apple: 'en-US' }, { id: 'tr', apple: 'tr' }])).toEqual({ 'en-US': 'Hi' }); expect(betaDescriptions({ description: 'All' }, [{ id: 'en', apple: 'en-US' }, { id: 'x' }])).toEqual({ 'en-US': 'All' }); const d = planBetaReviewDetail({ id: 'd1', attributes: { contactEmail: 'a@example.com', notes: null } }, { firstName: 'A', lastName: 'B', phone: '+1 555 0100', email: 'a@example.com' }, 'Tap Play.'); expect((d[0]!.body as { data: { attributes: unknown } }).data.attributes).toEqual({ contactFirstName: 'A', contactLastName: 'B', contactPhone: '+1 555 0100', notes: 'Tap Play.' }); expect(describeReq(d[0]!)).not.toContain('555'); expect(planBetaReviewDetail(null, null, 'x')).toEqual([]); expect(() => planBetaReviewDetail({ id: 'd', attributes: {} }, null, 'x'.repeat(4001))).toThrow('4000'); }); }); describe('testflight mcp tools', () => { const tool = (n: string) => TOOLS.find(t => t.name === n)!; test('status read-only, writes dry by default, distribute is internal-only, testers / review not exposed', () => { expect(tool('apple_testflight_status')).toMatchObject({ write: false, network: true }); expect(toArgv(tool('apple_testflight_groups')).argv).toEqual(['apple', 'testflight', 'groups', 'sync', '--dry']); expect(toArgv(tool('apple_testflight_what_to_test'), { build: '42', locale: 'en-US,tr' }).argv).toEqual(['apple', 'testflight', 'what-to-test', '--build', '42', '--locale', 'en-US,tr', '--dry']); expect(toArgv(tool('apple_testflight_distribute'), { build: '42', groups: 'Team', dry_run: false }).argv).toEqual(['apple', 'testflight', 'distribute', '--build', '42', '--group', 'Team', '--internal-only']); expect(validateInput(tool('apple_testflight_distribute'), { build: 'latest', groups: 'Team' })[0]).toContain('does not match'); expect(TOOLS.some(t => t.command.includes('testers'))).toBe(false); for (const t of TOOLS) expect(toArgv(t, { build: '1', groups: 'x', dry_run: false }).argv).not.toContain('--submit-review'); }); });