import Fastify from "fastify"; import { beforeEach, afterEach, describe, expect, it } from "vitest"; import { devices, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { qrReaderRoutes, splitChannel } from "./qr-reader.js"; import { CredentialCapture } from "../credential-capture.js"; import type { DeviceReadEvent, ReadOutcome } from "../device-events.js"; import type { ReadDispatcher } from "../read-dispatch.js"; // CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output // Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF. // The route strips the prefix, tags the read's confirmed channel, and enrollment // capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so // an unconfigured reader keeps working. These tests pin the route-side contract; // the match-side enforcement is pinned in ../subscription-channel.test.ts. const SERIAL = "H05MA5B0"; const READER_ID = "reader-exit"; let db: Db; let app: ReturnType; let capture: CredentialCapture; let seen: DeviceReadEvent[]; /** Dispatcher stub: records the event the route built, always rejects. */ const fakeDispatcher = { dispatch: async (e: DeviceReadEvent): Promise => { seen.push(e); return { accepted: false, reason: "test" }; }, } as unknown as ReadDispatcher; beforeEach(async () => { ({ db } = createTestDb()); db.insert(devices).values({ id: READER_ID, category: "reader", driverId: "dingtian-qr-reader", config: { serial: SERIAL }, enabled: true, }).run(); seen = []; capture = new CredentialCapture(); app = Fastify({ logger: false }); await qrReaderRoutes(app as never, db, fakeDispatcher, capture); }); afterEach(async () => { await app.close(); }); const scan = (cardid: string) => app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` }); describe("splitChannel", () => { it("K: prefix → bare value, kind card, channel rf", () => { expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" }); }); it("Q: prefix → bare value, kind qr, channel optical", () => { expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" }); }); it("no prefix → value untouched, legacy untagged qr", () => { expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" }); }); }); describe("qr-reader route channel tagging", () => { it("card-prefixed push dispatches a stripped, rf-tagged read", async () => { const res = await scan("K:86A158"); expect(res.statusCode).toBe(200); expect(seen).toHaveLength(1); expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID }); }); it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => { await scan("Q:00000000000"); expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" }); }); it("unprefixed push stays legacy: kind qr, no channel", async () => { await scan("86A158"); expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" }); expect(seen[0].channel).toBeUndefined(); }); it("a bare prefix (empty value after strip) dispatches nothing", async () => { await scan("K:"); expect(seen).toHaveLength(0); }); it("enrollment capture stores the BARE value, not the prefixed one", async () => { capture.arm(READER_ID); const res = await scan("K:86A158"); expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow const state = capture.state(); expect(state.status).toBe("captured"); if (state.status === "captured") expect(state.value).toBe("86A158"); // Beeps "ok" so the operator knows the card was read. expect(res.json().data[0].status).toBe(1); }); });