storebound/tests/testflight.test.ts

157 lines
13 KiB
TypeScript

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, unknownGroupMessage, 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('unknown groups: configured ones point at groups sync, others list what exists', () => {
const cfg = [{ name: 'Team', internal: true }, { name: 'QA' }];
expect(unknownGroupMessage(['Team'], [], cfg)).toBe('group "Team" is in the config but not on App Store Connect yet: create it with `storebound apple testflight groups sync`');
expect(unknownGroupMessage(['Team', 'QA'], [], cfg)).toContain('groups "Team", "QA" are in the config');
expect(unknownGroupMessage(['Nope'], G, cfg)).toBe('unknown group "Nope": App Store Connect has "Team", "Everyone inside", "Friends"');
expect(unknownGroupMessage(['Nope'], [], cfg)).toContain('no beta groups yet');
});
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');
});
});