storebound/tests/console-app-id.test.ts
CRT b39e44e6ab console fill: one app shown twice is one app; App Privacy opens by app id via the API
`console fill apple-app-privacy` without apple.appId stopped at "the app
in the list" with "more than one app matches: X | X" for an account with
one app: the Apps grid names each app twice (the icon link's aria-label
and the title link), both opening the same /apps/<id>/... page.

- pickApp tells hits apart by the app they open (/apps/<id>, else the
  link target), not by name. Two apps of one name with different targets
  still stop and are listed.
- Without apple.appId, but with App Store Connect API credentials, the
  app id is looked up from apple.bundleId (GET /v1/apps?filter[bundleId],
  as `apple status` does), cached in .storebound/console-app-ids.json, and
  App Privacy opens by deep link. Without credentials, or when the lookup
  fails, the Apps list by name is used, and the log says so.

Fixture asc-apps-card.html (a card with the name in the title and an
aria-label); tests for both paths.
2026-10-12 06:32:21 +08:00

70 lines
4.5 KiB
TypeScript

// console fill apple-app-privacy without apple.appId: the app id from the App Store Connect API (by bundle id, cached)
// -> deep link; no credentials / no app -> the Apps list by name. No network: the lookup is injected.
import { afterAll, describe, expect, test } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { appleAppId, withAppleAppId } from '../cli/console/app-id';
import { appKey } from '../cli/console/fill';
import type { ShipConfig } from '../cli/lib/config';
import { navPlan } from '../cli/lib/console-forms';
const tmp = mkdtempSync(join(tmpdir(), 'sb-app-id-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const config = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
apple: { bundleId: 'com.example.game', primaryLocale: 'en-US' },
} as ShipConfig;
describe('apple app id for console fill', () => {
test('API credentials: looked up by bundle id, cached, and the form opens by deep link', async () => {
const cacheFile = join(tmp, 'a', 'console-app-ids.json');
const said: string[] = [];
const asked: string[] = [];
const lookup = async (b: string) => { asked.push(b); return '6800000001'; };
const id = await appleAppId(tmp, config, { say: m => said.push(m), lookup, cacheFile });
expect(id).toBe('6800000001');
expect(asked).toEqual(['com.example.game']);
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toEqual({ 'com.example.game': '6800000001' });
expect(said.some(m => m.includes('Apple ID 6800000001 (App Store Connect API)'))).toBe(true);
const plan = navPlan('apple-app-privacy', withAppleAppId(config, id));
expect(plan.map(s => s.kind)).toEqual(['goto', 'wait']);
expect(plan[0]).toMatchObject({ url: 'https://appstoreconnect.apple.com/apps/6800000001/distribution/privacy' });
// the second run reads the cache, no API call
const again = await appleAppId(tmp, config, { say: () => {}, lookup: async () => { throw new Error('not called'); }, cacheFile });
expect(again).toBe('6800000001');
});
test('no API credentials: null, said, and the Apps list by name is used', async () => {
const cacheFile = join(tmp, 'b', 'console-app-ids.json');
const said: string[] = [];
const id = await appleAppId(tmp, config, { say: m => said.push(m), lookup: async () => { throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, or .storebound/credentials.json)'); }, cacheFile });
expect(id).toBeNull();
expect(existsSync(cacheFile)).toBe(false);
expect(said.join('\n')).toContain('finding the app in the Apps list by name');
const plan = navPlan('apple-app-privacy', withAppleAppId(config, id));
expect(plan.map(s => s.kind)).toEqual(['goto', 'pickApp', 'click', 'wait']);
});
test('no app with that bundle id: null; apple.appId in the config wins without a lookup; no bundle id: null', async () => {
const cacheFile = join(tmp, 'c', 'console-app-ids.json');
expect(await appleAppId(tmp, config, { say: () => {}, lookup: async () => null, cacheFile })).toBeNull();
const set = { ...config, apple: { ...config.apple!, appId: '123' } } as ShipConfig;
expect(await appleAppId(tmp, set, { say: () => {}, lookup: async () => { throw new Error('not called'); }, cacheFile })).toBe('123');
expect(withAppleAppId(set, '999')).toBe(set);
const noBundle = { ...config, apple: { primaryLocale: 'en-US' } } as unknown as ShipConfig;
expect(await appleAppId(tmp, noBundle, { say: () => {}, lookup: async () => { throw new Error('not called'); }, cacheFile })).toBeNull();
});
});
describe('appKey (one app shown twice in the list is one app)', () => {
test('App Store Connect app URLs by id, any sub page; other links by URL without fragment', () => {
expect(appKey('https://appstoreconnect.apple.com/apps/6800000001/distribution/ios/version/inflight')).toBe('apps/6800000001');
expect(appKey('https://appstoreconnect.apple.com/apps/6800000001/appstore')).toBe('apps/6800000001');
expect(appKey('https://appstoreconnect.apple.com/apps/6800000001')).toBe('apps/6800000001');
expect(appKey('http://127.0.0.1:1/asc-app.html?id=1#x')).toBe('http://127.0.0.1:1/asc-app.html?id=1');
expect(appKey(null)).toBeNull();
});
});