import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { appLogs, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { LogService, pinoDbStream } from "./log-service.js"; // pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger // emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable // container logs — and the stream must accept BOTH encodings (numeric covers any // default-configured pino). A level the tee can't resolve falls back to info → not // persisted, never a crash. let db: Db; let stream: { write: (line: string) => void }; let teed: string[]; beforeEach(() => { ({ db } = createTestDb()); teed = []; stream = pinoDbStream(new LogService(db), { write: (line: string) => { teed.push(line); return true; }, } as unknown as NodeJS.WritableStream); }); const rows = () => db.select().from(appLogs).all(); describe("pinoDbStream level encodings", () => { it("persists a LABEL-level warn line (the current logger format)", () => { stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`); expect(rows()).toHaveLength(1); expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" }); }); it("still persists a NUMERIC-level error line (legacy/default pino)", () => { stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`); expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" }); }); it("info stays stdout-only in both encodings (teed, not persisted)", () => { stream.write(`{"level":"info","msg":"label info"}\n`); stream.write(`{"level":30,"msg":"numeric info"}\n`); expect(rows()).toHaveLength(0); expect(teed).toHaveLength(2); // stdout tee always happens }); it("an unresolvable level falls back to info (dropped), never throws", () => { stream.write(`{"level":"loud","msg":"weird"}\n`); stream.write(`not json at all\n`); expect(rows()).toHaveLength(0); expect(teed).toHaveLength(2); }); }); // Storm coalescing: a line identical to the LAST persisted row (level+source+message+ // path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping // context._repeat) instead of inserting — one screaming device can't evict unrelated // history. The row's createdAt tracks the LATEST occurrence; the first is preserved in // context._firstAt. describe("storm coalescing", () => { afterEach(() => { vi.useRealTimers(); }); it("folds a burst of identical error lines into ONE row with a repeat counter", () => { for (let i = 0; i < 200; i++) { stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`); } const all = rows(); expect(all).toHaveLength(1); expect(all[0].context).toMatchObject({ _repeat: 200 }); expect(teed).toHaveLength(200); // stdout still gets every line }); it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z")); stream.write(`{"level":"warn","msg":"same"}\n`); vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z")); stream.write(`{"level":"warn","msg":"same"}\n`); const [row] = rows(); expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z"); expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" }); }); it("a different message (or level) breaks the run — separate rows", () => { stream.write(`{"level":"error","msg":"boom A"}\n`); stream.write(`{"level":"error","msg":"boom A"}\n`); stream.write(`{"level":"error","msg":"boom B"}\n`); stream.write(`{"level":"warn","msg":"boom B"}\n`); expect(rows()).toHaveLength(3); }); it("an occurrence past the 5-minute window starts a fresh row", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z")); stream.write(`{"level":"error","msg":"slow leak"}\n`); vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z")); stream.write(`{"level":"error","msg":"slow leak"}\n`); expect(rows()).toHaveLength(2); }); it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => { vi.useFakeTimers(); let t = new Date("2026-07-08T10:00:00.000Z").getTime(); for (let i = 0; i < 10; i++) { vi.setSystemTime(new Date(t)); stream.write(`{"level":"error","msg":"storm"}\n`); t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit } const all = rows(); expect(all).toHaveLength(1); expect(all[0].context).toMatchObject({ _repeat: 10 }); }); });