import { assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { denoDeployApi, denoHostingProvider } from "#shared/deno-deploy-api.ts"; import { okResult } from "#shared/result.ts"; import { expectIncludesAll } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { stubFetch } from "#test-utils/fetch-stub.ts"; import { withVirtualBackoff } from "#test-utils/virtual-time.ts"; const DENO_ENV = { DENO_DEPLOY_ORG_ID: "test-org-id", DENO_DEPLOY_ORG_SLUG: "test-org", DENO_DEPLOY_TOKEN: "test-deno-token", }; interface CapturedRequest { body: unknown; contentType?: string | null; method?: string; url: string | undefined; } /** Capture the request URL + JSON body and respond with `responseBody`. */ const captureRequest = (responseBody: unknown, captured: CapturedRequest) => (url: string, init?: RequestInit): Response => { captured.url = url; captured.body = JSON.parse(init?.body as string); captured.contentType = new Headers(init?.headers).get("content-type"); if (init?.method !== undefined) captured.method = init.method; return new Response(JSON.stringify(responseBody)); }; describeWithEnv("deno-deploy-api", { env: DENO_ENV }, () => { // ── createApp ────────────────────────────────────────────────────────────── test("createApp POSTs to /v2/apps with orgId and slug", async () => { const captured: CapturedRequest = { body: undefined, url: undefined }; using _fetch = stubFetch( captureRequest({ id: "app_abc123", slug: "my-app" }, captured), ); const result = await denoDeployApi.createApp("my-app"); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.appId).toBe("app_abc123"); expect(result.value.slug).toBe("my-app"); } assertStringIncludes(captured.url ?? "", "/v2/apps"); expect(captured.body).toEqual({ orgId: "test-org-id", slug: "my-app" }); expect(captured.contentType).toBe("application/json"); expect(captured.method).toBe("POST"); }); test("createApp uses Bearer auth header", async () => { let authHeader: string | undefined; using _fetch = stubFetch((_url, init) => { authHeader = (init?.headers as Record)?.Authorization; return new Response(JSON.stringify({ id: "app_auth", slug: "auth-app" })); }); await denoDeployApi.createApp("auth-app"); expect(authHeader).toBe("Bearer test-deno-token"); }); test("createApp returns error when API responds with failure", async () => { using _fetch = stubFetch( new Response(JSON.stringify({ message: "Invalid slug" }), { status: 400, }), ); const result = await denoDeployApi.createApp("bad"); expect(result.ok).toBe(false); if (!result.ok) { expectIncludesAll(result.error, [ "Create app failed (400)", "Invalid slug", ]); } }); // ── setEnvVars ───────────────────────────────────────────────────────────── test("setEnvVars PATCHes only the supplied vars (no GET)", async () => { const captured: CapturedRequest = { body: undefined, url: undefined }; let callCount = 0; using _fetch = stubFetch((url, init) => { callCount++; return captureRequest({ id: "app_ev", slug: "env-app" }, captured)( url, init, ); }); const result = await denoDeployApi.setEnvVars("app_ev", [ ["NEW_VAR", "new-value"], ]); expect(result.ok).toBe(true); expect(callCount).toBe(1); assertStringIncludes(captured.url ?? "", "/apps/app_ev"); expect(captured.method).toBe("PATCH"); expect(captured.body).toEqual({ env_vars: [ { contexts: ["production"], key: "NEW_VAR", secret: true, value: "new-value", }, ], }); }); test("setEnvVars returns error when PATCH fails", async () => { using _fetch = stubFetch( new Response(JSON.stringify({ error: "invalid env var name" }), { status: 422, }), ); const result = await denoDeployApi.setEnvVars("app_pe", [["BAD VAR", "v"]]); expect(result.ok).toBe(false); if (!result.ok) { expectIncludesAll(result.error, [ "Set app env vars failed (422)", "invalid env var name", ]); } }); test("setEnvVar PATCHes one secret, production entry by default", async () => { const captured: CapturedRequest = { body: undefined, url: undefined }; using _fetch = stubFetch(captureRequest({ env_vars: [] }, captured)); const result = await denoDeployApi.setEnvVar("app_sv", { key: "SUPPORT_PAGE_TEXT", value: "# Hi", }); expect(result.ok).toBe(true); expect(captured.method).toBe("PATCH"); expect(captured.url).toBe("https://api.deno.com/v2/apps/app_sv"); expect(captured.body).toEqual({ env_vars: [ { contexts: ["production"], key: "SUPPORT_PAGE_TEXT", secret: true, value: "# Hi", }, ], }); }); test("setEnvVar passes a plain entry's own secret flag and contexts", async () => { const captured: CapturedRequest = { body: undefined, url: undefined }; using _fetch = stubFetch(captureRequest({ env_vars: [] }, captured)); const result = await denoDeployApi.setEnvVar("app_plain", { contexts: ["preview"], key: "SUPPORT_PAGE_TEXT", secret: false, value: "# Hi", }); expect(result.ok).toBe(true); expect(captured.url).toBe("https://api.deno.com/v2/apps/app_plain"); expect(captured.method).toBe("PATCH"); expect(captured.body).toEqual({ env_vars: [ { contexts: ["preview"], key: "SUPPORT_PAGE_TEXT", secret: false, value: "# Hi", }, ], }); }); test("setEnvVar returns the labelled error when PATCH fails", async () => { using _fetch = stubFetch(new Response("{}", { status: 401 })); const result = await denoDeployApi.setEnvVar("app_svf", { key: "SUPPORT_PAGE_TEXT", value: "# Hi", }); expect(result.ok).toBe(false); if (!result.ok) { assertStringIncludes(result.error, "Set app env var failed (401)"); } }); test("getAppEnvVars returns the labelled error when GET fails", async () => { using _fetch = stubFetch(new Response("{}", { status: 401 })); const result = await denoDeployApi.getAppEnvVars("app_gev"); expect(result.ok).toBe(false); if (!result.ok) { assertStringIncludes(result.error, "Get app failed (401)"); } }); // ── deployCode ───────────────────────────────────────────────────────────── test("deployCode POSTs assets and config to the revision endpoint", async () => { const captured: CapturedRequest = { body: undefined, url: undefined }; using _fetch = stubFetch( captureRequest({ id: "dep_123", status: "succeeded" }, captured), ); const result = await denoDeployApi.deployCode( "app_dc", "console.log('hello')", ); expect(result).toEqual({ ok: true, value: undefined }); expect(captured.url).toBe("https://api.deno.com/v2/apps/app_dc/deploy"); expect(captured.method).toBe("POST"); expect(captured.body).toEqual({ assets: { "main.ts": { content: "console.log('hello')", encoding: "utf-8", kind: "file", }, }, config: { runtime: { entrypoint: "main.ts", type: "dynamic" } }, production: true, }); }); test("deployCode waits for an accepted revision to succeed", async () => { const requests: string[] = []; const requestTimes: number[] = []; const responses = [ { id: "revision-1", status: "queued" }, { id: "revision-1", status: "building" }, { id: "revision-1", status: "succeeded" }, ]; using _fetch = stubFetch((url) => { requests.push(String(url)); requestTimes.push(Date.now()); return Response.json(responses.shift()); }); expect( await withVirtualBackoff(() => denoDeployApi.deployCode("app_1", "code")), ).toEqual({ ok: true, value: undefined }); expect(requests).toEqual([ "https://api.deno.com/v2/apps/app_1/deploy", "https://api.deno.com/v2/revisions/revision-1", "https://api.deno.com/v2/revisions/revision-1", ]); expect(requestTimes.map((time) => time - requestTimes[0]!)).toEqual([ 0, 1_000, 2_000, ]); }); test("deployCode reports a failed accepted revision", async () => { using _fetch = stubFetch( Response.json({ failure_reason: "error", id: "revision-failed", status: "failed", }), ); expect(await denoDeployApi.deployCode("app_1", "code")).toEqual({ error: "Deno revision revision-failed failed: error", ok: false, }); }); test("deployCode reports a skipped accepted revision", async () => { using _fetch = stubFetch( Response.json({ id: "revision-skipped", status: "skipped" }), ); expect(await denoDeployApi.deployCode("app_1", "code")).toEqual({ error: "Deno revision revision-skipped skipped: skipped", ok: false, }); }); test("deployCode reports a revision that never finishes", async () => { using _fetch = stubFetch(() => Response.json({ id: "revision-pending", status: "queued" }), ); expect( await withVirtualBackoff(() => denoDeployApi.deployCode("app_1", "code")), ).toEqual({ error: "Deno revision revision-pending did not finish within 20 seconds", ok: false, }); }); test("deployCode reports repeated revision read failures", async () => { const calls = { value: 0 }; using _fetch = stubFetch(() => { calls.value += 1; return calls.value === 1 ? Response.json({ id: "revision-unreadable", status: "queued" }) : new Response("unavailable", { status: 503 }); }); expect( await withVirtualBackoff(() => denoDeployApi.deployCode("app_1", "code")), ).toEqual({ error: "Deno revision revision-unreadable could not be read: Get revision failed (503): unavailable", ok: false, }); }); test("deployCode rejects an undocumented revision response", async () => { using _fetch = stubFetch( Response.json({ id: "revision-unknown", status: "running" }), ); await expect(denoDeployApi.deployCode("app_1", "code")).rejects.toThrow(); }); test("deployCode returns error when API responds with failure", async () => { using _fetch = stubFetch( new Response(JSON.stringify({ message: "app not found" }), { status: 404, }), ); const result = await denoDeployApi.deployCode("app_missing", "code"); expect(result.ok).toBe(false); if (!result.ok) { expectIncludesAll(result.error, [ "Deploy code failed (404)", "app not found", ]); } }); test("hosting provider names its required credential", () => { expect(denoHostingProvider.configEnvVar).toBe("DENO_DEPLOY_TOKEN"); }); test("hosting provider builds the Deno v2 production domain", async () => { using _create = stub(denoDeployApi, "createApp", () => Promise.resolve(okResult({ appId: "app_1", slug: "child" })), ); using _secrets = stub(denoDeployApi, "setEnvVars", () => Promise.resolve(okResult(undefined)), ); expect(await denoHostingProvider.prepareSite("Child", "code", [])).toEqual({ ok: true, value: { defaultHostname: "https://child.test-org.deno.net", hostingId: "app_1", }, }); }); test("hosting publish returns only provider success", async () => { using _fetch = stubFetch(Response.json({ id: "dep", status: "succeeded" })); expect(await denoHostingProvider.publishSite("app_1", "code")).toEqual({ ok: true, }); }); test("hosting publish preserves provider failure", async () => { using _fetch = stubFetch(new Response("failed", { status: 500 })); expect(await denoHostingProvider.publishSite("app_1", "code")).toEqual({ error: "Deploy code failed (500): failed", ok: false, }); }); });