storebound/tests/testflight.test.ts
CRT 3f12452e3f TestFlight through the App Store Connect API: apple testflight status | groups | testers | what-to-test | info | distribute
Beta groups from apple.testflight.groups (created when missing, never changed), testers from a CSV in .storebound/
(masked in logs, confirmation before Apple emails invitations, --remove / --invite), What to Test per locale from the
App Store listing (whatToTest or whatsNew), Test Information and Beta App Review details, builds by exact number to
groups, Beta App Review submission behind --submit-review and a confirmation. Pure planners in cli/lib/testflight.ts,
shared CSV / table / JSON:API helpers. MCP: apple_testflight_status, _groups, _what_to_test, _info, _distribute
(internal groups only). Skill testflight.
2026-10-10 23:28:10 +08:00

150 lines
12 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, 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', '--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');
});
});