CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
post-deploy-smoke.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.
| b1be050 | 1 | /** |
| 2 | * Block S1+S3 — post-deploy smoke suite tests. | |
| 3 | * | |
| 4 | * Covers the pure layer of `src/lib/post-deploy-smoke.ts`: | |
| 5 | * - CHECKS array shape (every check has name + url + at least one | |
| 6 | * expectation) | |
| 7 | * - assertion helpers (assertStatus / assertKey / assertContains) | |
| 8 | * - the runner returns ok=false when ANY check fails, ok=true when all | |
| 9 | * green | |
| 10 | * - missingMigrations + latestMigration helpers | |
| 11 | * | |
| 12 | * No real network, no mock pollution: every test supplies its own | |
| 13 | * fetch impl via DI. | |
| 14 | */ | |
| 15 | ||
| 16 | import { describe, it, expect } from "bun:test"; | |
| 17 | import { | |
| 18 | CHECKS, | |
| 19 | assertStatus, | |
| 20 | assertKey, | |
| 21 | assertContains, | |
| 22 | runChecks, | |
| 23 | formatTable, | |
| 24 | missingMigrations, | |
| 25 | latestMigration, | |
| 26 | type Check, | |
| 27 | type FetchLike, | |
| 28 | } from "../lib/post-deploy-smoke"; | |
| 29 | ||
| 30 | // ─── helpers ──────────────────────────────────────────────────────── | |
| 31 | ||
| 32 | function res(status: number, body: string): { status: number; text: () => Promise<string> } { | |
| 33 | return { status, text: async () => body }; | |
| 34 | } | |
| 35 | ||
| 36 | function jsonRes(status: number, obj: unknown) { | |
| 37 | return res(status, JSON.stringify(obj)); | |
| 38 | } | |
| 39 | ||
| 40 | function alwaysOkFetch(): FetchLike { | |
| 41 | return async (url) => { | |
| 42 | // Default-OK responses that pass every shipped check in CHECKS. | |
| 43 | if (url.endsWith("/healthz")) | |
| 44 | return jsonRes(200, { ok: true, uptimeMs: 1 }); | |
| 45 | if (url.endsWith("/readyz")) return jsonRes(200, { ok: true }); | |
| 46 | if (url.endsWith("/api/version")) | |
| 47 | return jsonRes(200, { sha: "abcdef0", branch: "main", builtAt: "x", uptimeMs: 1 }); | |
| 48 | if (url.endsWith("/login")) | |
| 49 | return res(200, "<html><body><h2>Sign in</h2></body></html>"); | |
| 50 | if (url.endsWith("/register")) | |
| 51 | return res(200, "<html><body><h2>Create account</h2></body></html>"); | |
| 52 | if (url.endsWith("/mcp")) | |
| 53 | return jsonRes(200, { serverInfo: { name: "gluecron" } }); | |
| 54 | if (url.endsWith("/api/v2/healthz")) return res(404, "not found"); | |
| 55 | if (url.endsWith("/demo")) return res(302, ""); | |
| 56 | return res(200, "<html></html>"); | |
| 57 | }; | |
| 58 | } | |
| 59 | ||
| 60 | // ─── CHECKS array shape ───────────────────────────────────────────── | |
| 61 | ||
| 62 | describe("CHECKS array shape", () => { | |
| 63 | it("has at least the 15 critical endpoints the owner specified", () => { | |
| 64 | expect(CHECKS.length).toBeGreaterThanOrEqual(15); | |
| 65 | }); | |
| 66 | ||
| 67 | it("every check has a non-empty name + url + at least one expectation", () => { | |
| 68 | for (const c of CHECKS) { | |
| 69 | expect(typeof c.name).toBe("string"); | |
| 70 | expect(c.name.length).toBeGreaterThan(0); | |
| 71 | expect(typeof c.url).toBe("string"); | |
| 72 | expect(c.url.startsWith("/")).toBe(true); | |
| 73 | // expectStatus is required; either expectKey or expectContains may | |
| 74 | // additionally be supplied but a check is valid with just status. | |
| 75 | expect(c.expectStatus !== undefined).toBe(true); | |
| 76 | } | |
| 77 | }); | |
| 78 | ||
| 79 | it("covers the specific endpoints the spec called out", () => { | |
| 80 | const names = CHECKS.map((c) => c.name); | |
| 81 | for (const required of [ | |
| 82 | "healthz", | |
| 83 | "readyz", | |
| 84 | "version", | |
| 85 | "login renders", | |
| 86 | "register renders", | |
| 87 | "landing renders", | |
| 88 | "explore renders", | |
| 89 | "demo renders", | |
| 90 | "pricing renders", | |
| 91 | "status renders", | |
| 92 | "api v2 health", | |
| 93 | "mcp discovery", | |
| 94 | "manifest", | |
| 95 | "sw", | |
| 96 | "dxt download", | |
| 97 | ]) { | |
| 98 | expect(names).toContain(required); | |
| 99 | } | |
| 100 | }); | |
| 101 | ||
| 102 | it("api v2 health accepts 200 OR 404 (route is optional)", () => { | |
| 103 | const v2 = CHECKS.find((c) => c.name === "api v2 health"); | |
| 104 | expect(v2).toBeDefined(); | |
| 105 | expect(Array.isArray(v2!.expectStatus)).toBe(true); | |
| 106 | expect((v2!.expectStatus as number[]).sort()).toEqual([200, 404]); | |
| 107 | }); | |
| 108 | }); | |
| 109 | ||
| 110 | // ─── Assertion helpers ────────────────────────────────────────────── | |
| 111 | ||
| 112 | describe("assertStatus", () => { | |
| 113 | it("returns null when the actual status matches a single expected", () => { | |
| 114 | expect(assertStatus(200, 200)).toBeNull(); | |
| 115 | }); | |
| 116 | ||
| 117 | it("returns null when the actual status matches one of an array", () => { | |
| 118 | expect(assertStatus(404, [200, 404])).toBeNull(); | |
| 119 | expect(assertStatus(200, [200, 404])).toBeNull(); | |
| 120 | }); | |
| 121 | ||
| 122 | it("returns a descriptive error when the status doesn't match", () => { | |
| 123 | const err = assertStatus(500, 200); | |
| 124 | expect(err).not.toBeNull(); | |
| 125 | expect(err!).toContain("200"); | |
| 126 | expect(err!).toContain("500"); | |
| 127 | }); | |
| 128 | ||
| 129 | it("returns an error including all acceptable codes when given an array", () => { | |
| 130 | const err = assertStatus(500, [200, 404]); | |
| 131 | expect(err!).toContain("200"); | |
| 132 | expect(err!).toContain("404"); | |
| 133 | }); | |
| 134 | }); | |
| 135 | ||
| 136 | describe("assertKey", () => { | |
| 137 | it("returns null when the JSON has the key", () => { | |
| 138 | expect(assertKey(`{"ok":true}`, "ok")).toBeNull(); | |
| 139 | expect(assertKey(`{"sha":"abc","x":1}`, "sha")).toBeNull(); | |
| 140 | }); | |
| 141 | ||
| 142 | it("returns null even when the key's value is null/0/empty (presence-only)", () => { | |
| 143 | expect(assertKey(`{"ok":null}`, "ok")).toBeNull(); | |
| 144 | expect(assertKey(`{"ok":0}`, "ok")).toBeNull(); | |
| 145 | expect(assertKey(`{"ok":""}`, "ok")).toBeNull(); | |
| 146 | }); | |
| 147 | ||
| 148 | it("returns an error when the body isn't JSON", () => { | |
| 149 | expect(assertKey("<html></html>", "ok")).not.toBeNull(); | |
| 150 | }); | |
| 151 | ||
| 152 | it("returns an error when the body is JSON but lacks the key", () => { | |
| 153 | expect(assertKey(`{"other":1}`, "ok")).not.toBeNull(); | |
| 154 | }); | |
| 155 | ||
| 156 | it("returns an error when the body is a JSON array (not an object)", () => { | |
| 157 | expect(assertKey(`[1,2,3]`, "ok")).not.toBeNull(); | |
| 158 | }); | |
| 159 | ||
| 160 | it("doesn't fall for prototype-pollution lookups", () => { | |
| 161 | // `__proto__` is not an own property of {}, so the helper must | |
| 162 | // treat it as missing. (Object.prototype.hasOwnProperty.call is the | |
| 163 | // implementation choice.) | |
| 164 | expect(assertKey(`{}`, "toString")).not.toBeNull(); | |
| 165 | expect(assertKey(`{}`, "__proto__")).not.toBeNull(); | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | describe("assertContains", () => { | |
| 170 | it("returns null when the body contains the substring", () => { | |
| 171 | expect(assertContains("hello world", "world")).toBeNull(); | |
| 172 | }); | |
| 173 | ||
| 174 | it("returns an error including the missing substring", () => { | |
| 175 | const err = assertContains("hello", "world"); | |
| 176 | expect(err).not.toBeNull(); | |
| 177 | expect(err!).toContain("world"); | |
| 178 | }); | |
| 179 | ||
| 180 | it("is case-sensitive (matches the production check exactly)", () => { | |
| 181 | expect(assertContains("Sign In", "Sign in")).not.toBeNull(); | |
| 182 | }); | |
| 183 | }); | |
| 184 | ||
| 185 | // ─── runChecks runner ─────────────────────────────────────────────── | |
| 186 | ||
| 187 | describe("runChecks", () => { | |
| 188 | it("returns ok=true and failed=0 when every check passes", async () => { | |
| 189 | const summary = await runChecks({ | |
| 190 | baseUrl: "http://localhost:3010", | |
| 191 | fetchImpl: alwaysOkFetch(), | |
| 192 | }); | |
| 193 | expect(summary.ok).toBe(true); | |
| 194 | expect(summary.failed).toBe(0); | |
| 195 | expect(summary.passed).toBe(summary.results.length); | |
| 196 | expect(summary.results.length).toBe(CHECKS.length); | |
| 197 | }); | |
| 198 | ||
| 199 | it("returns ok=false and failed>=1 when any check fails", async () => { | |
| 200 | const fetchImpl: FetchLike = async (url) => { | |
| 201 | // Break /readyz specifically. Everything else passes. | |
| 202 | if (url.endsWith("/readyz")) return res(503, "db down"); | |
| 203 | return alwaysOkFetch()(url); | |
| 204 | }; | |
| 205 | const summary = await runChecks({ | |
| 206 | baseUrl: "http://localhost:3010", | |
| 207 | fetchImpl, | |
| 208 | }); | |
| 209 | expect(summary.ok).toBe(false); | |
| 210 | expect(summary.failed).toBeGreaterThanOrEqual(1); | |
| 211 | const readyzResult = summary.results.find((r) => r.name === "readyz")!; | |
| 212 | expect(readyzResult.ok).toBe(false); | |
| 213 | expect(readyzResult.status).toBe(503); | |
| 214 | expect(readyzResult.error).toContain("503"); | |
| 215 | }); | |
| 216 | ||
| 217 | it("records a failure when the fetch impl itself throws", async () => { | |
| 218 | const checks: Check[] = [{ name: "x", url: "/x", expectStatus: 200 }]; | |
| 219 | const fetchImpl: FetchLike = async () => { | |
| 220 | throw new Error("ECONNREFUSED"); | |
| 221 | }; | |
| 222 | const summary = await runChecks({ | |
| 223 | baseUrl: "http://localhost:3010", | |
| 224 | checks, | |
| 225 | fetchImpl, | |
| 226 | }); | |
| 227 | expect(summary.ok).toBe(false); | |
| 228 | expect(summary.results[0].error).toContain("fetch failed"); | |
| 229 | expect(summary.results[0].error).toContain("ECONNREFUSED"); | |
| 230 | }); | |
| 231 | ||
| 232 | it("fails a check that expects a JSON key when the body is HTML", async () => { | |
| 233 | const checks: Check[] = [ | |
| 234 | { name: "x", url: "/x", expectStatus: 200, expectKey: "sha" }, | |
| 235 | ]; | |
| 236 | const fetchImpl: FetchLike = async () => res(200, "<html>not json</html>"); | |
| 237 | const summary = await runChecks({ | |
| 238 | baseUrl: "http://localhost:3010", | |
| 239 | checks, | |
| 240 | fetchImpl, | |
| 241 | }); | |
| 242 | expect(summary.ok).toBe(false); | |
| 243 | expect(summary.results[0].error).toContain("JSON"); | |
| 244 | }); | |
| 245 | ||
| 246 | it("fails a check that expects a substring when the body lacks it", async () => { | |
| 247 | const checks: Check[] = [ | |
| 248 | { | |
| 249 | name: "x", | |
| 250 | url: "/x", | |
| 251 | expectStatus: 200, | |
| 252 | expectContains: "Sign in", | |
| 253 | }, | |
| 254 | ]; | |
| 255 | const fetchImpl: FetchLike = async () => res(200, "<html>Welcome</html>"); | |
| 256 | const summary = await runChecks({ | |
| 257 | baseUrl: "http://localhost:3010", | |
| 258 | checks, | |
| 259 | fetchImpl, | |
| 260 | }); | |
| 261 | expect(summary.ok).toBe(false); | |
| 262 | expect(summary.results[0].error).toContain("Sign in"); | |
| 263 | }); | |
| 264 | ||
| 265 | it("hits checks sequentially in declared order", async () => { | |
| 266 | const calls: string[] = []; | |
| 267 | const checks: Check[] = [ | |
| 268 | { name: "a", url: "/a", expectStatus: 200 }, | |
| 269 | { name: "b", url: "/b", expectStatus: 200 }, | |
| 270 | { name: "c", url: "/c", expectStatus: 200 }, | |
| 271 | ]; | |
| 272 | const fetchImpl: FetchLike = async (url) => { | |
| 273 | calls.push(url); | |
| 274 | return res(200, ""); | |
| 275 | }; | |
| 276 | await runChecks({ baseUrl: "http://x", checks, fetchImpl }); | |
| 277 | expect(calls).toEqual(["http://x/a", "http://x/b", "http://x/c"]); | |
| 278 | }); | |
| 279 | ||
| 280 | it("records a duration_ms field on every result using the injected clock", async () => { | |
| 281 | let t = 0; | |
| 282 | const checks: Check[] = [{ name: "x", url: "/x", expectStatus: 200 }]; | |
| 283 | const fetchImpl: FetchLike = async () => res(200, ""); | |
| 284 | const summary = await runChecks({ | |
| 285 | baseUrl: "http://x", | |
| 286 | checks, | |
| 287 | fetchImpl, | |
| 288 | now: () => (t += 17), | |
| 289 | }); | |
| 290 | expect(summary.results[0].durationMs).toBe(17); | |
| 291 | }); | |
| 292 | }); | |
| 293 | ||
| 294 | // ─── formatTable ──────────────────────────────────────────────────── | |
| 295 | ||
| 296 | describe("formatTable", () => { | |
| 297 | it("renders a header row with all four columns", () => { | |
| 298 | const table = formatTable([ | |
| 299 | { name: "x", url: "/x", status: 200, durationMs: 5, ok: true }, | |
| 300 | ]); | |
| 301 | expect(table).toContain("name"); | |
| 302 | expect(table).toContain("status"); | |
| 303 | expect(table).toContain("duration_ms"); | |
| 304 | expect(table).toContain("result"); | |
| 305 | expect(table).toContain("PASS"); | |
| 306 | }); | |
| 307 | ||
| 308 | it("renders FAIL with the error message for failed rows", () => { | |
| 309 | const table = formatTable([ | |
| 310 | { | |
| 311 | name: "x", | |
| 312 | url: "/x", | |
| 313 | status: 500, | |
| 314 | durationMs: 5, | |
| 315 | ok: false, | |
| 316 | error: "expected status 200, got 500", | |
| 317 | }, | |
| 318 | ]); | |
| 319 | expect(table).toContain("FAIL"); | |
| 320 | expect(table).toContain("500"); | |
| 321 | }); | |
| 322 | }); | |
| 323 | ||
| 324 | // ─── Migration verification helpers ───────────────────────────────── | |
| 325 | ||
| 326 | describe("missingMigrations", () => { | |
| 327 | it("returns [] when every file is applied", () => { | |
| 328 | const files = ["0001_init.sql", "0002_users.sql", "0003_repos.sql"]; | |
| 329 | const applied = ["0001_init.sql", "0002_users.sql", "0003_repos.sql"]; | |
| 330 | expect(missingMigrations(files, applied)).toEqual([]); | |
| 331 | }); | |
| 332 | ||
| 333 | it("returns the unapplied files sorted", () => { | |
| 334 | const files = [ | |
| 335 | "0001_init.sql", | |
| 336 | "0002_users.sql", | |
| 337 | "0050_a.sql", | |
| 338 | "0051_b.sql", | |
| 339 | "0053_c.sql", | |
| 340 | ]; | |
| 341 | const applied = ["0001_init.sql", "0002_users.sql", "0050_a.sql"]; | |
| 342 | expect(missingMigrations(files, applied)).toEqual([ | |
| 343 | "0051_b.sql", | |
| 344 | "0053_c.sql", | |
| 345 | ]); | |
| 346 | }); | |
| 347 | ||
| 348 | it("ignores non-sql files", () => { | |
| 349 | const files = ["0001_init.sql", "README.md", "0002_x.sql"]; | |
| 350 | const applied: string[] = []; | |
| 351 | expect(missingMigrations(files, applied)).toEqual([ | |
| 352 | "0001_init.sql", | |
| 353 | "0002_x.sql", | |
| 354 | ]); | |
| 355 | }); | |
| 356 | ||
| 357 | it("returns [] when the file list is empty", () => { | |
| 358 | expect(missingMigrations([], ["any.sql"])).toEqual([]); | |
| 359 | }); | |
| 360 | ||
| 361 | it("treats the applied list as a set (duplicates don't matter)", () => { | |
| 362 | const files = ["0001.sql", "0002.sql"]; | |
| 363 | const applied = ["0001.sql", "0001.sql", "0002.sql"]; | |
| 364 | expect(missingMigrations(files, applied)).toEqual([]); | |
| 365 | }); | |
| 366 | }); | |
| 367 | ||
| 368 | describe("latestMigration", () => { | |
| 369 | it("returns the lexicographic max .sql", () => { | |
| 370 | expect( | |
| 371 | latestMigration(["0001_init.sql", "0050_x.sql", "0010_y.sql"]) | |
| 372 | ).toBe("0050_x.sql"); | |
| 373 | }); | |
| 374 | ||
| 375 | it("returns null on empty input", () => { | |
| 376 | expect(latestMigration([])).toBeNull(); | |
| 377 | }); | |
| 378 | ||
| 379 | it("ignores non-sql files", () => { | |
| 380 | expect(latestMigration(["0001_init.sql", "README.md", "z.txt"])).toBe( | |
| 381 | "0001_init.sql" | |
| 382 | ); | |
| 383 | }); | |
| 384 | ||
| 385 | it("returns null when only non-sql files are present", () => { | |
| 386 | expect(latestMigration(["README.md", "z.txt"])).toBeNull(); | |
| 387 | }); | |
| 388 | }); |