Blame · Line-by-line history
platform-siblings.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.
| f295f78 | 1 | /** |
| 2 | * Cross-product platform-status aggregator + admin widget smoke tests. | |
| 3 | * | |
| 4 | * The real siblings (crontech.ai, gluecron.com, gatetest.io) aren't | |
| 5 | * reachable in test, so we stub `globalThis.fetch` to exercise the three | |
| 6 | * branches: healthy JSON, non-2xx, and outright failure. Each branch must | |
| 7 | * resolve — a sibling being down never crashes the widget. | |
| 8 | */ | |
| 9 | ||
| 10 | import { describe, it, expect, afterEach, beforeEach } from "bun:test"; | |
| 11 | import app from "../app"; | |
| 12 | import { | |
| 13 | getSiblingStatuses, | |
| 14 | siblingUrls, | |
| 15 | __resetSiblingCache, | |
| 16 | } from "../lib/platform-siblings"; | |
| 17 | ||
| 18 | const originalFetch = globalThis.fetch; | |
| 19 | ||
| 20 | beforeEach(() => { | |
| 21 | __resetSiblingCache(); | |
| 22 | }); | |
| 23 | ||
| 24 | afterEach(() => { | |
| 25 | globalThis.fetch = originalFetch; | |
| 26 | __resetSiblingCache(); | |
| 27 | delete process.env.CRONTECH_STATUS_URL; | |
| 28 | delete process.env.GLUECRON_STATUS_URL; | |
| 29 | delete process.env.GATETEST_STATUS_URL; | |
| 30 | }); | |
| 31 | ||
| 32 | describe("platform-siblings — siblingUrls", () => { | |
| 33 | it("returns defaults pointing at production hosts", () => { | |
| 34 | const urls = siblingUrls(); | |
| 35 | expect(urls.crontech).toBe("https://crontech.ai/api/platform-status"); | |
| 36 | expect(urls.gluecron).toBe("https://gluecron.com/api/platform-status"); | |
| 37 | expect(urls.gatetest).toBe("https://gatetest.io/api/platform-status"); | |
| 38 | }); | |
| 39 | ||
| 40 | it("honours env var overrides", () => { | |
| 41 | process.env.CRONTECH_STATUS_URL = "https://example.com/ct"; | |
| 42 | process.env.GLUECRON_STATUS_URL = "https://example.com/gl"; | |
| 43 | process.env.GATETEST_STATUS_URL = "https://example.com/gt"; | |
| 44 | const urls = siblingUrls(); | |
| 45 | expect(urls.crontech).toBe("https://example.com/ct"); | |
| 46 | expect(urls.gluecron).toBe("https://example.com/gl"); | |
| 47 | expect(urls.gatetest).toBe("https://example.com/gt"); | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("platform-siblings — getSiblingStatuses", () => { | |
| 52 | it("reports healthy when sibling returns healthy JSON", async () => { | |
| 53 | globalThis.fetch = (async () => | |
| 54 | new Response( | |
| 55 | JSON.stringify({ | |
| 56 | product: "x", | |
| 57 | version: "1.2.3", | |
| 58 | commit: "abcdef1234567", | |
| 59 | healthy: true, | |
| 60 | timestamp: "2026-04-20T00:00:00Z", | |
| 61 | }), | |
| 62 | { status: 200, headers: { "content-type": "application/json" } } | |
| 63 | )) as unknown as typeof fetch; | |
| 64 | ||
| 65 | const rows = await getSiblingStatuses({ force: true }); | |
| 66 | expect(rows).toHaveLength(3); | |
| 67 | for (const r of rows) { | |
| 68 | expect(r.reachable).toBe(true); | |
| 69 | expect(r.healthy).toBe(true); | |
| 70 | expect(r.version).toBe("1.2.3"); | |
| 71 | expect(r.commit).toBe("abcdef1234567"); | |
| 72 | expect(r.error).toBeNull(); | |
| 73 | expect(typeof r.latencyMs).toBe("number"); | |
| 74 | } | |
| 75 | }); | |
| 76 | ||
| 77 | it("reports degraded (reachable but unhealthy) on non-2xx", async () => { | |
| 78 | globalThis.fetch = (async () => | |
| 79 | new Response("nope", { status: 503 })) as unknown as typeof fetch; | |
| 80 | ||
| 81 | const rows = await getSiblingStatuses({ force: true }); | |
| 82 | for (const r of rows) { | |
| 83 | expect(r.reachable).toBe(true); | |
| 84 | expect(r.healthy).toBe(false); | |
| 85 | expect(r.error).toBe("HTTP 503"); | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | it("reports unreachable on fetch error, never throws", async () => { | |
| 90 | globalThis.fetch = (async () => { | |
| 91 | throw new Error("ECONNREFUSED"); | |
| 92 | }) as unknown as typeof fetch; | |
| 93 | ||
| 94 | const rows = await getSiblingStatuses({ force: true }); | |
| 95 | expect(rows).toHaveLength(3); | |
| 96 | for (const r of rows) { | |
| 97 | expect(r.reachable).toBe(false); | |
| 98 | expect(r.healthy).toBe(false); | |
| 99 | expect(r.error).toBeTruthy(); | |
| 100 | } | |
| 101 | }); | |
| 102 | ||
| 103 | it("serves a cached result on the second call", async () => { | |
| 104 | let calls = 0; | |
| 105 | globalThis.fetch = (async () => { | |
| 106 | calls++; | |
| 107 | return new Response( | |
| 108 | JSON.stringify({ healthy: true, version: "v", commit: "c" }), | |
| 109 | { status: 200 } | |
| 110 | ); | |
| 111 | }) as unknown as typeof fetch; | |
| 112 | ||
| 113 | await getSiblingStatuses({ force: true }); | |
| 114 | expect(calls).toBe(3); | |
| 115 | await getSiblingStatuses(); | |
| 116 | expect(calls).toBe(3); | |
| 117 | }); | |
| 118 | }); | |
| 119 | ||
| 120 | describe("platform-status endpoint", () => { | |
| 121 | it("GET /api/platform-status returns our own health JSON", async () => { | |
| 122 | const res = await app.request("/api/platform-status"); | |
| 123 | expect(res.status).toBe(200); | |
| 124 | const body = (await res.json()) as any; | |
| 125 | expect(body.product).toBe("gluecron"); | |
| 126 | expect(body.healthy).toBe(true); | |
| 127 | expect(typeof body.timestamp).toBe("string"); | |
| 128 | expect(body.siblings).toBeDefined(); | |
| 129 | expect(res.headers.get("access-control-allow-origin")).toBe("*"); | |
| 130 | }); | |
| 131 | }); | |
| 132 | ||
| 133 | describe("admin platform widget — auth gate", () => { | |
| 134 | it("GET /admin/platform without auth → 302 /login", async () => { | |
| 135 | const res = await app.request("/admin/platform"); | |
| 136 | expect(res.status).toBe(302); | |
| 137 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 138 | }); | |
| 139 | }); |