Blame · Line-by-line history
crontech-client.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| a6d8fd5 | 1 | /** |
| 2 | * Block K — Crontech client tests. | |
| 3 | * | |
| 4 | * Exercises config toggle, auth headers, and offline short-circuits. When a | |
| 5 | * key is present, we mock globalThis.fetch to confirm graceful degradation. | |
| 6 | */ | |
| 7 | ||
| 8 | import { describe, it, expect, beforeEach, afterEach } from "bun:test"; | |
| 9 | import { | |
| 10 | buildAuthHeaders, | |
| 11 | getDeploymentForCommit, | |
| 12 | isConfigured, | |
| 13 | rollbackDeployment, | |
| 14 | triggerRedeploy, | |
| 15 | watchDeployment, | |
| 16 | } from "../lib/crontech-client"; | |
| 17 | ||
| 18 | const ENV_KEYS = ["CRONTECH_API_KEY", "CRONTECH_BASE_URL"] as const; | |
| 19 | ||
| 20 | let savedEnv: Record<string, string | undefined> = {}; | |
| 21 | let savedFetch: typeof fetch; | |
| 22 | ||
| 23 | beforeEach(() => { | |
| 24 | savedEnv = {}; | |
| 25 | for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; | |
| 26 | for (const k of ENV_KEYS) delete process.env[k]; | |
| 27 | savedFetch = globalThis.fetch; | |
| 28 | }); | |
| 29 | ||
| 30 | afterEach(() => { | |
| 31 | for (const k of ENV_KEYS) { | |
| 32 | const v = savedEnv[k]; | |
| 33 | if (v === undefined) delete process.env[k]; | |
| 34 | else process.env[k] = v; | |
| 35 | } | |
| 36 | globalThis.fetch = savedFetch; | |
| 37 | }); | |
| 38 | ||
| 39 | // --------------------------------------------------------------------------- | |
| 40 | // isConfigured + buildAuthHeaders | |
| 41 | // --------------------------------------------------------------------------- | |
| 42 | ||
| 43 | describe("crontech-client — config", () => { | |
| 44 | it("isConfigured is false when no API key is set", () => { | |
| 45 | expect(isConfigured()).toBe(false); | |
| 46 | }); | |
| 47 | ||
| 48 | it("isConfigured flips true once CRONTECH_API_KEY is set", () => { | |
| 49 | process.env.CRONTECH_API_KEY = "ct-test"; | |
| 50 | expect(isConfigured()).toBe(true); | |
| 51 | }); | |
| 52 | ||
| 53 | it("buildAuthHeaders omits Authorization when no key is present", () => { | |
| 54 | const h = buildAuthHeaders(); | |
| 55 | expect(h["Content-Type"]).toBe("application/json"); | |
| 56 | expect(h["Authorization"]).toBeUndefined(); | |
| 57 | }); | |
| 58 | ||
| 59 | it("buildAuthHeaders includes a Bearer token when the key is present", () => { | |
| 60 | process.env.CRONTECH_API_KEY = "ct-live"; | |
| 61 | const h = buildAuthHeaders(); | |
| 62 | expect(h["Authorization"]).toBe("Bearer ct-live"); | |
| 63 | }); | |
| 64 | }); | |
| 65 | ||
| 66 | // --------------------------------------------------------------------------- | |
| 67 | // getDeploymentForCommit | |
| 68 | // --------------------------------------------------------------------------- | |
| 69 | ||
| 70 | describe("crontech-client — getDeploymentForCommit", () => { | |
| 71 | it("returns null in offline mode (no key)", async () => { | |
| 72 | globalThis.fetch = (() => { | |
| 73 | throw new Error("fetch must not run offline"); | |
| 74 | }) as unknown as typeof fetch; | |
| 75 | const result = await getDeploymentForCommit({ | |
| 76 | repo: "o/r", | |
| 77 | commitSha: "deadbeef", | |
| 78 | }); | |
| 79 | expect(result).toBeNull(); | |
| 80 | }); | |
| 81 | ||
| 82 | it("returns null on a 500 response", async () => { | |
| 83 | process.env.CRONTECH_API_KEY = "ct"; | |
| 84 | globalThis.fetch = (async () => | |
| 85 | new Response("", { status: 500 })) as unknown as typeof fetch; | |
| 86 | const result = await getDeploymentForCommit({ | |
| 87 | repo: "o/r", | |
| 88 | commitSha: "deadbeef", | |
| 89 | }); | |
| 90 | expect(result).toBeNull(); | |
| 91 | }); | |
| 92 | ||
| 93 | it("parses a valid deployment body on 200", async () => { | |
| 94 | process.env.CRONTECH_API_KEY = "ct"; | |
| 95 | globalThis.fetch = (async () => | |
| 96 | new Response( | |
| 97 | JSON.stringify({ | |
| 98 | deployId: "dep_1", | |
| 99 | commitSha: "deadbeef", | |
| 100 | status: "live", | |
| 101 | environment: "production", | |
| 102 | startedAt: "2026-01-01T00:00:00Z", | |
| 103 | }), | |
| 104 | { status: 200, headers: { "Content-Type": "application/json" } } | |
| 105 | )) as unknown as typeof fetch; | |
| 106 | const result = await getDeploymentForCommit({ | |
| 107 | repo: "o/r", | |
| 108 | commitSha: "deadbeef", | |
| 109 | }); | |
| 110 | expect(result).not.toBeNull(); | |
| 111 | expect(result?.deployId).toBe("dep_1"); | |
| 112 | expect(result?.status).toBe("live"); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | // --------------------------------------------------------------------------- | |
| 117 | // triggerRedeploy | |
| 118 | // --------------------------------------------------------------------------- | |
| 119 | ||
| 120 | describe("crontech-client — triggerRedeploy", () => { | |
| 121 | it("returns null in offline mode (no key)", async () => { | |
| 122 | const result = await triggerRedeploy({ | |
| 123 | repo: "o/r", | |
| 124 | commitSha: "cafef00d", | |
| 125 | }); | |
| 126 | expect(result).toBeNull(); | |
| 127 | }); | |
| 128 | ||
| 129 | it("returns null on a 500 response", async () => { | |
| 130 | process.env.CRONTECH_API_KEY = "ct"; | |
| 131 | globalThis.fetch = (async () => | |
| 132 | new Response("", { status: 500 })) as unknown as typeof fetch; | |
| 133 | const result = await triggerRedeploy({ | |
| 134 | repo: "o/r", | |
| 135 | commitSha: "cafef00d", | |
| 136 | environment: "staging", | |
| 137 | }); | |
| 138 | expect(result).toBeNull(); | |
| 139 | }); | |
| 140 | }); | |
| 141 | ||
| 142 | // --------------------------------------------------------------------------- | |
| 143 | // rollbackDeployment | |
| 144 | // --------------------------------------------------------------------------- | |
| 145 | ||
| 146 | describe("crontech-client — rollbackDeployment", () => { | |
| 147 | it("returns false in offline mode (no key)", async () => { | |
| 148 | const ok = await rollbackDeployment({ repo: "o/r", deployId: "dep_1" }); | |
| 149 | expect(ok).toBe(false); | |
| 150 | }); | |
| 151 | ||
| 152 | it("returns false on a 500 response", async () => { | |
| 153 | process.env.CRONTECH_API_KEY = "ct"; | |
| 154 | globalThis.fetch = (async () => | |
| 155 | new Response("", { status: 500 })) as unknown as typeof fetch; | |
| 156 | const ok = await rollbackDeployment({ repo: "o/r", deployId: "dep_1" }); | |
| 157 | expect(ok).toBe(false); | |
| 158 | }); | |
| 159 | ||
| 160 | it("returns false when deployId is empty even with a key", async () => { | |
| 161 | process.env.CRONTECH_API_KEY = "ct"; | |
| 162 | const ok = await rollbackDeployment({ repo: "o/r", deployId: "" }); | |
| 163 | expect(ok).toBe(false); | |
| 164 | }); | |
| 165 | }); | |
| 166 | ||
| 167 | // --------------------------------------------------------------------------- | |
| 168 | // watchDeployment | |
| 169 | // --------------------------------------------------------------------------- | |
| 170 | ||
| 171 | describe("crontech-client — watchDeployment", () => { | |
| 172 | it("returns offline:true immediately when no key is set", async () => { | |
| 173 | globalThis.fetch = (() => { | |
| 174 | throw new Error("fetch must not run offline"); | |
| 175 | }) as unknown as typeof fetch; | |
| 176 | const start = Date.now(); | |
| 177 | const result = await watchDeployment({ | |
| 178 | repo: "o/r", | |
| 179 | deployId: "dep_1", | |
| 180 | maxWaitMs: 60_000, | |
| 181 | }); | |
| 182 | const elapsed = Date.now() - start; | |
| 183 | expect(result.offline).toBe(true); | |
| 184 | expect(result.finalStatus).toBe("failed"); | |
| 185 | expect(result.errors).toEqual([]); | |
| 186 | // Should short-circuit in well under a second. | |
| 187 | expect(elapsed).toBeLessThan(500); | |
| 188 | }); | |
| 189 | ||
| 190 | it("resolves with finalStatus=live when the poll returns terminal status", async () => { | |
| 191 | process.env.CRONTECH_API_KEY = "ct"; | |
| 192 | let call = 0; | |
| 193 | globalThis.fetch = (async (input: unknown) => { | |
| 194 | call++; | |
| 195 | const url = String(input); | |
| 196 | if (url.endsWith("/status")) { | |
| 197 | return new Response( | |
| 198 | JSON.stringify({ status: "live" }), | |
| 199 | { status: 200, headers: { "Content-Type": "application/json" } } | |
| 200 | ); | |
| 201 | } | |
| 202 | if (url.endsWith("/errors")) { | |
| 203 | return new Response( | |
| 204 | JSON.stringify({ errors: [] }), | |
| 205 | { status: 200, headers: { "Content-Type": "application/json" } } | |
| 206 | ); | |
| 207 | } | |
| 208 | return new Response("", { status: 404 }); | |
| 209 | }) as unknown as typeof fetch; | |
| 210 | ||
| 211 | const result = await watchDeployment({ | |
| 212 | repo: "o/r", | |
| 213 | deployId: "dep_1", | |
| 214 | maxWaitMs: 5_000, | |
| 215 | pollIntervalMs: 50, | |
| 216 | }); | |
| 217 | expect(result.offline).toBe(false); | |
| 218 | expect(result.finalStatus).toBe("live"); | |
| 219 | expect(call).toBeGreaterThanOrEqual(2); // status + errors | |
| 220 | }); | |
| 221 | ||
| 222 | it("bails to offline after repeated 500 status polls", async () => { | |
| 223 | process.env.CRONTECH_API_KEY = "ct"; | |
| 224 | globalThis.fetch = (async () => | |
| 225 | new Response("", { status: 500 })) as unknown as typeof fetch; | |
| 226 | const result = await watchDeployment({ | |
| 227 | repo: "o/r", | |
| 228 | deployId: "dep_1", | |
| 229 | maxWaitMs: 5_000, | |
| 230 | pollIntervalMs: 10, | |
| 231 | }); | |
| 232 | expect(result.offline).toBe(true); | |
| 233 | expect(result.finalStatus).toBe("failed"); | |
| 234 | }); | |
| 235 | }); |