Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

post-deploy-smoke.test.tsBlame388 lines · 1 contributor
b1be050CC LABS App1/**
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
16import { describe, it, expect } from "bun:test";
17import {
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
32function res(status: number, body: string): { status: number; text: () => Promise<string> } {
33 return { status, text: async () => body };
34}
35
36function jsonRes(status: number, obj: unknown) {
37 return res(status, JSON.stringify(obj));
38}
39
40function 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
62describe("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
112describe("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
136describe("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
169describe("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
187describe("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
296describe("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
326describe("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
368describe("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});