/** * Realtime integration (TASK-0091, acceptance criteria 5 + 7): room GC, * the SIGTERM drain, and the `/health` + `/readyz` probes. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildRealtimeFixture, connectClient, fetchJson, makeTextUpdate, type RealtimeFixture, } from "./rt-helpers"; import * as Y from "yjs"; import { WS_CLOSE } from "@factory/shared/realtime/frames"; /** Join, send one update, leave — leaves the room dirty with 1 update. */ async function touch(fx: RealtimeFixture, room: string, text: string): Promise { const c = await connectClient(fx, { room }); try { await c.nextFrame("hello"); const doc = new Y.Doc(); c.sendBinary(1, makeTextUpdate(doc, text)); await c.nextFrame("ack"); doc.destroy(); } finally { c.close(); } } describe("room GC (R2)", () => { it("a dirty room (unsaved updates) that loses its last peer flushes, then evicts", async () => { // Short GC window (the 5 min warm room, compressed for the test). const fx = await buildRealtimeFixture({ pages: new Set(["p-gc"]), workspaces: { "p-gc": "w1" }, levels: { "page:p-gc": "edit" }, roomGcMs: 500, }); try { await touch(fx, "page:p-gc", "gc-update"); const room = fx.server.manager.get("page:p-gc"); expect(room).toBeDefined(); expect(room!.isDirty).toBe(true); // The GC fires after 500 ms of emptiness: flush + evict. const deadline = Date.now() + 5_000; let gone = false; while (Date.now() < deadline && !gone) { await new Promise((r) => setTimeout(r, 100)); gone = fx.server.manager.get("page:p-gc") === undefined; } expect(gone, "room should have been evicted").toBe(true); // The dirty state was persisted BEFORE eviction (the store holds it // and the log was cleared with the room). const blocks = await fx.store.hydrate("p-gc"); expect(blocks).not.toBeNull(); expect(fx.updateLog.size("page:p-gc")).toBe(0); } finally { await fx.stop(); } }); it("a peer rejoining before the GC window cancels the eviction", async () => { const fx = await buildRealtimeFixture({ pages: new Set(["p-gc2"]), workspaces: { "p-gc2": "w1" }, levels: { "page:p-gc2": "edit" }, roomGcMs: 400, }); try { await touch(fx, "page:p-gc2", "one"); // Rejoin within the 400 ms window → the GC timer is cancelled. const c = await connectClient(fx, { room: "page:p-gc2" }); try { await c.nextFrame("hello"); await new Promise((r) => setTimeout(r, 900)); // past 2× the GC window expect(fx.server.manager.get("page:p-gc2")).toBeDefined(); } finally { c.close(); } } finally { await fx.stop(); } }); }); describe("SIGTERM drain (R6)", () => { it("drain() closes every socket with 4010 + retryAfterMs, flushes all dirty rooms, and stops", async () => { const fx = await buildRealtimeFixture({ pages: new Set(["p-d1", "p-d2", "p-d3"]), workspaces: { "p-d1": "w1", "p-d2": "w1", "p-d3": "w1" }, levels: { "page:p-d1": "edit", "page:p-d2": "edit", "page:p-d3": "edit" }, }); try { // 3 dirty rooms (the drain must flush ALL of them). await touch(fx, "page:p-d1", "drain-1"); await touch(fx, "page:p-d2", "drain-2"); await touch(fx, "page:p-d3", "drain-3"); for (const k of ["page:p-d1", "page:p-d2", "page:p-d3"]) { expect(fx.server.manager.get(k)?.isDirty).toBe(true); } // Live clients on two of the rooms — they must see the 4010. const a = await connectClient(fx, { room: "page:p-d1" }); const b = await connectClient(fx, { room: "page:p-d2" }); await a.nextFrame("hello"); await b.nextFrame("hello"); const started = Date.now(); await fx.server.drain(); const elapsed = Date.now() - started; expect(elapsed).toBeLessThanOrEqual(10_000); // the drain budget // Every live socket closed with the shutdown code + retry hint. const ca = await a.onClose(3_000); const cb = await b.onClose(3_000); expect(ca.code).toBe(WS_CLOSE.ServerShutdown); expect(cb.code).toBe(WS_CLOSE.ServerShutdown); expect(ca.reason).toContain("retryAfterMs"); // The error notice frame (server_shutdown) precedes the close. expect(a.textFrames().some((f) => f.type === "error" && f.code === "server_shutdown")).toBe(true); // ALL 3 dirty rooms were persisted (the flush happened during the // drain, not after it). for (const id of ["p-d1", "p-d2", "p-d3"]) { const blocks = await fx.store.hydrate(id); expect(blocks, `room ${id} not flushed`).not.toBeNull(); } // The manager is empty (every room evicted). expect(fx.server.manager.rooms()).toHaveLength(0); } finally { await fx.stop().catch(() => undefined); // already stopped by the drain } }); }); describe("/health + /readyz (R6)", () => { let fx: RealtimeFixture; beforeAll(async () => { fx = await buildRealtimeFixture({ pages: new Set(["p-it"]), workspaces: { "p-it": "w1" }, levels: { "page:p-it": "edit" }, }); }); afterAll(async () => { await fx.stop(); }); it("/health returns the documented shape (status ok, connections, rooms, uptime, memoryRss)", async () => { const r = await fetchJson(`${fx.healthUrl}/health`); expect(r.status).toBe(200); expect(r.body.status).toBe("ok"); expect(r.body).toHaveProperty("connections"); expect(r.body).toHaveProperty("rooms"); expect(r.body).toHaveProperty("uptime"); expect(Number(r.body.memoryRss)).toBeGreaterThan(0); }); it("/readyz is 200 ready with the checks object while the seams are up", async () => { const r = await fetchJson(`${fx.healthUrl}/readyz`); expect(r.status).toBe(200); expect(r.body.status).toBe("ready"); expect(r.body.checks).toMatchObject({ ws: "ok", database: "ok" }); }); it("/readyz is 503 not_ready when the database seam reports down", async () => { fx.store.setPingOk(false); try { const r = await fetchJson(`${fx.healthUrl}/readyz`); expect(r.status).toBe(503); expect(r.body.status).toBe("not_ready"); expect(r.body.checks).toMatchObject({ database: "error" }); // /health stays 200 (liveness ≠ readiness — the probe contract). const h = await fetchJson(`${fx.healthUrl}/health`); expect(h.status).toBe(200); } finally { fx.store.setPingOk(true); } }); it("the legacy /health/ready path serves the same readiness (Phase-0 compose)", async () => { const r = await fetchJson(`${fx.healthUrl}/health/ready`); expect(r.status).toBe(200); expect(r.body.status).toBe("ready"); }); it("/metrics serves the Prometheus text format", async () => { const r = await fetch(`${fx.healthUrl}/metrics`); expect(r.status).toBe(200); const text = await r.text(); expect(text).toContain("ws_connections"); expect(text).toContain("# HELP"); }); it("the ws port ALSO serves the health shape (note 15 — both ports probed)", async () => { const httpUrl = fx.wsUrl.replace(/^ws:\/\//, "http://"); const r = await fetchJson(`${httpUrl}/health`); expect(r.status).toBe(200); expect(r.body.status).toBe("ok"); }); });