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.
348 lines
13 KiB
JavaScript
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);
|
|
});
|