Files
pleno-vue/tests/node/app-store-connect.test.mjs
T
Jeppe B bf2208e77b fix(ios): use valid Danish TestFlight locale (#222)
Use Apple's supported `da` beta locale and cover the localization/distribution flow with a regression test.

The first signed upload already processed version 1.0.0 build 1 successfully; this fixes the post-processing localization failure before the controlled retry.
2026-07-23 16:50:37 +02:00

348 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
APP_STORE_CONNECT_BASE_URL,
APP_STORE_CONNECT_V2_BASE_URL,
EXPECTED_RELEASE_TYPE,
TESTFLIGHT_BETA_LOCALE,
appStoreVersionsPath,
createAppStoreConnectClient,
} from "../../scripts/mobile/app-store-connect.mjs";
const baseEnvironment = (overrides = {}) => ({
APP_STORE_CONNECT_APP_ID: "6792777794",
IOS_BUNDLE_ID: "io.truckwash.app",
IOS_MARKETING_VERSION: "1.0.0",
IOS_BUILD_NUMBER: "8",
EXPECTED_APP_STORE_BUILD_ID: "build-8",
...overrides,
});
const jsonResponse = (body, status = 200, headers = {}) => ({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? "OK" : "Failure",
headers: { get: (name) => headers[name.toLowerCase()] ?? null },
text: async () => (body === null ? "" : JSON.stringify(body)),
});
const makeClient = ({ environment = baseEnvironment(), handler, sleeps = [], outputs = [] }) => {
const calls = [];
const client = createAppStoreConnectClient({
environment,
tokenProvider: () => "private-test-token",
fetchImpl: async (url, options) => {
calls.push({ url, options });
return handler(url, options, calls.length);
},
sleepImpl: async (milliseconds) => sleeps.push(milliseconds),
outputWriter: (key, value) => outputs.push([key, value]),
logger: { log() {} },
});
return { client, calls, sleeps, outputs };
};
const appResponse = () =>
jsonResponse({ data: { type: "apps", id: "6792777794", attributes: { bundleId: "io.truckwash.app" } } });
test("constructs the supported app-scoped App Store version URL", () => {
const path = appStoreVersionsPath({ appId: "6792777794", version: "1.0.0", includeBuild: true });
assert.match(path, /^\/apps\/6792777794\/appStoreVersions\?/u);
assert.match(path, /filter%5Bplatform%5D=IOS/u);
assert.match(path, /filter%5BversionString%5D=1\.0\.0/u);
assert.match(path, /include=build/u);
assert.doesNotMatch(path, /filter%5Bapp%5D/u);
});
test("allocates the next build number across paginated App Store results", async () => {
const { client, calls, outputs } = makeClient({
handler: (url) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.includes("/apps/6792777794/appStoreVersions?")) {
return jsonResponse({ data: [], links: { next: null } });
}
if (url.includes("/builds?") && !url.includes("cursor=next")) {
return jsonResponse({
data: [{ attributes: { version: "3" } }, { attributes: { version: "invalid" } }],
links: { next: `${APP_STORE_CONNECT_BASE_URL}/builds?cursor=next` },
});
}
if (url.endsWith("/builds?cursor=next")) {
return jsonResponse({ data: [{ attributes: { version: "7" } }], links: { next: null } });
}
throw new Error(`Unexpected URL ${url}`);
},
});
assert.equal(await client.nextBuildNumber(), 8);
assert.deepEqual(outputs, [["build_number", "8"]]);
assert.equal(calls.filter(({ url }) => url.includes("/builds?")).length, 2);
});
test("refuses to allocate another build for a released store version", async () => {
const { client } = makeClient({
handler: (url) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.includes("/appStoreVersions?")) {
return jsonResponse({
data: [{ id: "version-1", attributes: { versionString: "1.0.0", appStoreState: "READY_FOR_SALE" } }],
});
}
throw new Error(`Unexpected URL ${url}`);
},
});
await assert.rejects(client.nextBuildNumber(), /already released/u);
});
test("configures automatic release after approval on the exact store version", async () => {
const { client, calls, outputs } = makeClient({
handler: (url, options) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.includes("/apps/6792777794/appStoreVersions?")) {
return jsonResponse({
data: [{ id: "version-1", attributes: { versionString: "1.0.0", appStoreState: "PREPARE_FOR_SUBMISSION" } }],
});
}
if (url.endsWith("/appStoreVersions/version-1") && options.method === "PATCH") {
return jsonResponse({ data: { id: "version-1", type: "appStoreVersions" } });
}
throw new Error(`Unexpected URL ${url}`);
},
});
assert.equal(await client.configureReleasePolicy(), "version-1");
const patch = calls.find(({ options }) => options.method === "PATCH");
assert.deepEqual(JSON.parse(patch.options.body), {
data: {
type: "appStoreVersions",
id: "version-1",
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
},
});
assert.deepEqual(outputs, [["app_store_version_id", "version-1"]]);
});
test("reads back the exact bundle, version, build ID, build number, and release policy", async () => {
const { client, outputs } = makeClient({
handler: (url) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.includes("/apps/6792777794/appStoreVersions?")) {
return jsonResponse({
data: [
{
id: "version-1",
attributes: {
versionString: "1.0.0",
appStoreState: "PREPARE_FOR_SUBMISSION",
releaseType: "AFTER_APPROVAL",
},
relationships: { build: { data: { type: "builds", id: "build-8" } } },
},
],
included: [{ type: "builds", id: "build-8", attributes: { version: "8" } }],
});
}
throw new Error(`Unexpected URL ${url}`);
},
});
const storeVersion = await client.verifyStoreVersion();
assert.equal(storeVersion.id, "version-1");
assert.deepEqual(outputs, [
["app_store_version_id", "version-1"],
["app_store_state", "PREPARE_FOR_SUBMISSION"],
["release_type", "AFTER_APPROVAL"],
]);
});
test("fails readback when the exact build or automatic release policy drifts", async () => {
const response = (releaseType = "MANUAL") =>
jsonResponse({
data: [
{
id: "version-1",
attributes: { versionString: "1.0.0", appStoreState: "PREPARE_FOR_SUBMISSION", releaseType },
relationships: { build: { data: { type: "builds", id: "build-9" } } },
},
],
included: [{ type: "builds", id: "build-9", attributes: { version: "9" } }],
});
const { client } = makeClient({
handler: (url) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.includes("/appStoreVersions?")) return response();
throw new Error(`Unexpected URL ${url}`);
},
});
await assert.rejects(client.verifyStoreVersion(), /not attached to build 8/u);
const { client: policyClient } = makeClient({
handler: (url) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.includes("/appStoreVersions?")) {
return jsonResponse({
data: [
{
id: "version-1",
attributes: {
versionString: "1.0.0",
appStoreState: "PREPARE_FOR_SUBMISSION",
releaseType: "MANUAL",
},
relationships: { build: { data: { type: "builds", id: "build-8" } } },
},
],
included: [{ type: "builds", id: "build-8", attributes: { version: "8" } }],
});
}
throw new Error(`Unexpected URL ${url}`);
},
});
await assert.rejects(policyClient.verifyStoreVersion(), /expected AFTER_APPROVAL/u);
});
test("verifies Denmark-only availability with preorder and future territories disabled", async () => {
const { client, calls, outputs } = makeClient({
handler: (url) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.endsWith("/apps/6792777794/appAvailabilityV2")) {
return jsonResponse({
data: {
type: "appAvailabilities",
id: "availability-1",
attributes: { availableInNewTerritories: false },
},
});
}
if (url.startsWith(`${APP_STORE_CONNECT_V2_BASE_URL}/appAvailabilities/availability-1/`)) {
return jsonResponse({
data: [
{
type: "territoryAvailabilities",
id: "availability-dnk",
attributes: { available: true, preOrderEnabled: false },
relationships: { territory: { data: { type: "territories", id: "DNK" } } },
},
{
type: "territoryAvailabilities",
id: "availability-swe",
attributes: { available: false, preOrderEnabled: false },
relationships: { territory: { data: { type: "territories", id: "SWE" } } },
},
],
});
}
throw new Error(`Unexpected URL ${url}`);
},
});
assert.deepEqual(await client.verifyAvailability(), ["DNK"]);
assert.deepEqual(outputs, [["available_territories", "DNK"]]);
assert.equal(calls.at(-1).url.includes("include=territory"), true);
});
test("rejects broader availability, preorder, or automatic future territories", async () => {
const makeAvailabilityClient = (attributes, territories = []) =>
makeClient({
handler: (url) => {
if (url.endsWith("/apps/6792777794")) return appResponse();
if (url.endsWith("/apps/6792777794/appAvailabilityV2")) {
return jsonResponse({ data: { id: "availability-1", attributes } });
}
return jsonResponse({ data: territories });
},
}).client;
await assert.rejects(
makeAvailabilityClient({ availableInNewTerritories: true }).verifyAvailability(),
/must not automatically include new territories/u
);
await assert.rejects(
makeAvailabilityClient({ availableInNewTerritories: false }, [
{
attributes: { available: true, preOrderEnabled: true },
relationships: { territory: { data: { id: "DNK" } } },
},
]).verifyAvailability(),
/preorder must remain disabled/u
);
await assert.rejects(
makeAvailabilityClient(
{ availableInNewTerritories: false },
["DNK", "SWE"].map((id) => ({
attributes: { available: true, preOrderEnabled: false },
relationships: { territory: { data: { id } } },
}))
).verifyAvailability(),
/expected Denmark only/u
);
});
test("uses Apple's Danish beta locale when localizing and distributing a processed build", async () => {
const environment = baseEnvironment({
TESTFLIGHT_INTERNAL_GROUP_ID: "internal-qa",
TESTFLIGHT_WHAT_TO_TEST: "Test den verificerede build.",
});
const { client, calls, outputs } = makeClient({
environment,
handler: (url, options) => {
if (url.includes("/builds?")) {
return jsonResponse({
data: [{ type: "builds", id: "build-8", attributes: { version: "8", processingState: "VALID" } }],
});
}
if (url.includes("/betaBuildLocalizations?")) return jsonResponse({ data: [] });
if (url.endsWith("/betaBuildLocalizations") && options.method === "POST") {
return jsonResponse({ data: { type: "betaBuildLocalizations", id: "localization-1" } }, 201);
}
if (url.includes("/betaGroups/internal-qa/relationships/builds?")) return jsonResponse({ data: [] });
if (url.endsWith("/betaGroups/internal-qa/relationships/builds") && options.method === "POST") {
return jsonResponse(null, 204);
}
throw new Error(`Unexpected URL ${url}`);
},
});
assert.equal(TESTFLIGHT_BETA_LOCALE, "da");
assert.equal((await client.waitAndDistribute()).id, "build-8");
const localizationLookup = calls.find(({ url }) => url.includes("/betaBuildLocalizations?"));
assert.match(localizationLookup.url, /filter%5Blocale%5D=da(?:&|$)/u);
const localizationCreate = calls.find(
({ url, options }) => url.endsWith("/betaBuildLocalizations") && options.method === "POST"
);
assert.deepEqual(JSON.parse(localizationCreate.options.body).data.attributes, {
locale: "da",
whatsNew: "Test den verificerede build.",
});
assert.deepEqual(outputs, [["app_store_build_id", "build-8"]]);
});
test("retries transient responses without exposing the bearer token in errors", async () => {
const { client, calls, sleeps } = makeClient({
handler: (_url, _options, attempt) => {
if (attempt === 1) return jsonResponse({ errors: [{ detail: "try later" }] }, 429, { "retry-after": "1" });
return jsonResponse({ errors: [{ detail: "permission denied" }] }, 403);
},
});
await assert.rejects(client.request("/apps/6792777794"), (error) => {
assert.match(error.message, /permission denied/u);
assert.doesNotMatch(error.message, /private-test-token/u);
return true;
});
assert.equal(calls.length, 2);
assert.deepEqual(sleeps, [1_000]);
assert.equal(calls[0].options.headers.Authorization, "Bearer private-test-token");
});
test("rejects an App Store app ID that resolves to another bundle", async () => {
const { client } = makeClient({
handler: () => jsonResponse({ data: { attributes: { bundleId: "com.example.other" } } }),
});
await assert.rejects(client.verifyCredentials(), /expected io\.truckwash\.app/u);
});