/** * The site-build dry run's own module: with `SITE_BUILD_DRY_RUN` set, a mapped * call answers from a canned body with no network, the subrequest budget still * pays for the call, and successive canned builds draw successive ids. The * per-provider canned bodies are pinned by each provider's own mirrored * suite (update.test.ts, bunny-db.test.ts, bunny-cdn/). */ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { builderApi } from "#shared/builder.ts"; import { dryRunOrFetchText, runWithSiteBuildScope, } from "#shared/builder-dry-run.ts"; import { bunnyCdnApi } from "#shared/bunny-cdn.ts"; import { supportMessageApi } from "#shared/site-support-message.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { withEnv } from "#test-utils/env.ts"; import { stubFetch } from "#test-utils/fetch-stub.ts"; /** A fetch stub that fails the moment any real network is attempted. */ const noNetwork = (): ReturnType => stubFetch(() => { throw new Error("dry run must not touch the network"); }); /** The site-build dry-run flag, set for the current test only. */ const withDryRunEnv = (): ReturnType => withEnv({ SITE_BUILD_DRY_RUN: "true" }); describeWithEnv( "site-build dry run", { db: true, env: { BUNNY_API_KEY: "test-key", CAN_BUILD_SITES: "true" } }, () => { test("draws successive ids for successive canned creations", async () => { using _env = withDryRunEnv(); using _network = noNetwork(); // The first two canned creations in this process take ids 1 and 2, so // the counter starts at zero and advances by exactly one per creation. const [first, second] = await runWithSiteBuildScope(async () => [ await bunnyCdnApi.createEdgeScript("First", "export {};"), await bunnyCdnApi.createEdgeScript("Second", "export {};"), ]); expect(first).toEqual({ defaultHostname: "dry-run-1.invalid", ok: true, pullZoneId: 1, scriptId: 1, }); expect(second).toEqual({ defaultHostname: "dry-run-2.invalid", ok: true, pullZoneId: 2, scriptId: 2, }); }); test("answers a mapped call with a canned 200", async () => { using _env = withDryRunEnv(); using _network = noNetwork(); const response = await runWithSiteBuildScope(() => dryRunOrFetchText( "https://api.bunny.net/compute/script/1/secrets", () => ({ headers: {} }), ), ); expect(response.ok).toBe(true); expect(response.status).toBe(200); expect(response.text).toBe("{}"); }); test("answers a database token request with the canned token", async () => { using _env = withDryRunEnv(); using _network = noNetwork(); const tokenResponse = await runWithSiteBuildScope(() => dryRunOrFetchText( "https://api.bunny.net/database/v2/databases/1/auth/generate", () => ({ headers: {} }), ), ); expect(JSON.parse(tokenResponse.text)).toEqual({ token: "dry-run-db-token", }); }); test("answers a database read with an id-derived canned URL", async () => { using _env = withDryRunEnv(); using _network = noNetwork(); const getResponse = await runWithSiteBuildScope(() => dryRunOrFetchText( "https://api.bunny.net/database/v2/databases/7", () => ({ headers: {} }), ), ); expect(JSON.parse(getResponse.text)).toEqual({ db: { db_id: "7", name: "Dry run", url: "libsql://dry-run-7.invalid", }, }); }); test("counts every canned call against the subrequest budget", async () => { using _env = withDryRunEnv(); using _network = noNetwork(); const { getSubrequestUsage, runWithSubrequestBudget } = await import( "#shared/subrequest-budget.ts" ); const usage = runWithSubrequestBudget(async () => { await runWithSiteBuildScope(() => bunnyCdnApi.setEdgeScriptSecret(1, "DB_URL", "x"), ); return getSubrequestUsage().external; }); expect(await usage).toBe(1); }); test("builds and retains a whole site through the canned surface", async () => { using _env = withDryRunEnv(); using _network = noNetwork(); let retained = 0; const retainedId = { value: 0 }; const result = await builderApi.buildSite( { siteName: "00001" }, async (site) => { retained += 1; retainedId.value = Number(site.hostingId); return retainedId.value; }, ); expect(result.ok).toBe(true); expect(retained).toBe(1); expect(retainedId.value).toBeGreaterThan(0); }); test("performs the real fetch for a URL outside the surface", async () => { using _env = withDryRunEnv(); using _network = stubFetch(() => new Response("{}", { status: 200 })); const response = await dryRunOrFetchText( "https://unmapped.example/records", () => ({ headers: {} }), ); expect(_network.calls.length).toBe(1); expect(response.ok).toBe(true); }); test("performs the real fetch for an unmapped URL inside a build", async () => { using _env = withDryRunEnv(); using _network = stubFetch(() => new Response("{}", { status: 200 })); // The build scope answers mapped build calls; a URL the surface does // not map still runs for real, so a dry-run build fails loudly on an // unmapped call instead of silently proceeding. const response = await runWithSiteBuildScope(() => dryRunOrFetchText("https://unmapped.example/records", () => ({ headers: {}, })), ); expect(_network.calls.length).toBe(1); expect(response.ok).toBe(true); }); test("answers the seed write inside a build, live outside it", async () => { using _env = withDryRunEnv(); // A 401 distinguishes the real fetch from the canned 200: a Support // message save is not a build call, so with the flag on it still // reaches Bunny and reports the provider's refusal. The same call the // build's seed step makes runs inside the build scope and gets the // canned success. using _network = stubFetch(new Response("{}", { status: 401 })); const save = supportMessageApi.setSupportMessage( "bunny", "501", "# Saved", ); expect(await save).toEqual({ error: "Set support message failed (401): {}", ok: false, }); const seeded = await runWithSiteBuildScope(() => supportMessageApi.setSupportMessage("bunny", "501", "# Seed"), ); expect(seeded).toEqual({ ok: true, value: "# Seed" }); }); test("performs the real fetch for a near-miss of a mapped endpoint", async () => { using _env = withDryRunEnv(); using _network = stubFetch(() => new Response("{}", { status: 200 })); const { dryRunOrFetchText } = await import("#shared/builder-dry-run.ts"); // Near misses of mapped shapes: another origin that ends like a mapped // path, a pull-zone action the build never takes, a database path // deeper than the read endpoint, and query-bearing versions of mapped // paths. Every one must reach the real network. const nearMisses = [ "https://unmapped.example/compute/script", "https://unmapped.example/pullzone/123", "https://api.bunny.net/pullzone/123/addHostname", "https://api.bunny.net/database/v2/databases/7/backups", "https://api.bunny.net/database/v2/databases/7?backup=1", "https://api.bunny.net/compute/script/1/secrets?refresh=1", ]; for (const url of nearMisses) { const response = await dryRunOrFetchText(url, () => ({ headers: {} })); expect(response.ok, `at ${url}`).toBe(true); } expect(_network.calls.length).toBe(nearMisses.length); }); test("performs the real fetch when the flag is off", async () => { using _env = withEnv({ SITE_BUILD_DRY_RUN: undefined }); using _network = stubFetch(() => new Response("{}", { status: 200 })); const { getSubrequestUsage, runWithSubrequestBudget } = await import( "#shared/subrequest-budget.ts" ); const usage = await runWithSubrequestBudget(async () => { expect(await bunnyCdnApi.setEdgeScriptSecret(1, "DB_URL", "x")).toEqual( { ok: true }, ); expect(_network.calls.length).toBe(1); return getSubrequestUsage().external; }); expect(usage).toBe(1); }); }, );