import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { after, test } from "node:test"; import type { RowDataPacket } from "mysql2/promise"; import { readBillPayload, storePublishedBill } from "../lib/bills"; import { closePool, getPool } from "../lib/db"; import { SYNC_TABLE_NAMES } from "../lib/schema"; import { lookupBikes } from "../lib/status"; import { EPOCH_ISO, PULL_LIMIT, pullChanges, pushChanges } from "../lib/sync"; import { NO_BIKE, NOT_IN_WORKSHOP } from "../lib/workshop"; const hasDb = Boolean(process.env.DATABASE_URL); after(async () => { if (hasDb) await closePool(); }); function statements(sql: string): string[] { return sql .split("\n") .filter((line) => !line.trim().startsWith("--")) .join("\n") .split(";") .map((statement) => statement.trim()) .filter(Boolean); } async function ensureSchema(): Promise { const sql = readFileSync("db/001_init.sql", "utf8"); const pool = getPool(); for (const statement of statements(sql)) { await pool.query(statement); } } async function remove(table: string, id: string): Promise { try { await getPool().query(`DELETE FROM \`${table}\` WHERE id = ?`, [id]); } catch { // The deployed app user has no DELETE. Leftover rows stay soft-deleted. } } function customerCursors(cursor: { updatedAt: string; id: string }) { const future = { updatedAt: "9999-01-01T00:00:00.000Z", id: "" }; const cursors: Record = {}; for (const name of SYNC_TABLE_NAMES) cursors[name] = { ...future }; cursors.customers = cursor; return cursors; } async function findCustomer(id: string): Promise | undefined> { let cursor = { updatedAt: EPOCH_ISO, id: "" }; for (let page = 0; page < 50; page += 1) { const result = await pullChanges({ cursors: customerCursors(cursor) }); const row = result.tables.customers.find((item) => item.id === id); if (row) return row; if (result.tables.customers.length < PULL_LIMIT) return undefined; const next = result.nextCursors.customers; if (next.updatedAt === cursor.updatedAt && next.id === cursor.id) return undefined; cursor = next; } return undefined; } test("push pull soft-delete and an older push", { skip: !hasDb }, async () => { await ensureSchema(); const id = randomUUID(); const t1 = "2026-09-28T06:30:00.000Z"; const t2 = "2026-09-28T07:30:00.000Z"; const base = { id, phone: "771234567", name: "Ada", created_at: t1, updated_at: t1, deleted_at: null, is_dirty: true, }; try { const created = await pushChanges({ customers: [base] }); assert.deepEqual(created.accepted.customers, [id]); assert.deepEqual(created.rejected.customers, []); const pulled = await findCustomer(id); assert.ok(pulled); assert.equal(pulled.name, "Ada"); assert.equal(pulled.deleted_at, null); assert.equal(pulled.is_dirty, 0); assert.equal(pulled.phone, "771234567"); const removed = await pushChanges({ customers: [{ ...base, updated_at: t2, deleted_at: t2 }], }); assert.deepEqual(removed.accepted.customers, [id]); const deleted = await findCustomer(id); assert.ok(deleted); assert.equal(deleted.name, "Ada"); assert.equal(deleted.deleted_at, t2); const older = await pushChanges({ customers: [{ ...base, name: "Old", updated_at: t1, deleted_at: null }], }); assert.deepEqual(older.rejected.customers, [id]); assert.deepEqual(older.accepted.customers, []); const still = await findCustomer(id); assert.equal(still?.name, "Ada"); assert.equal(still?.deleted_at, t2); } finally { await remove("customers", id); } }); test("legacy bill publish does not insert invoices.id = token", { skip: !hasDb }, async () => { await ensureSchema(); const token = randomUUID(); const invoiceId = randomUUID(); const t1 = "2026-09-28T06:30:00.000Z"; const body = JSON.stringify({ shopName: "100kmph", address: "Service Centre", phone: "", number: "INV-0001", token, createdAt: t1, kind: "job", customerName: "Ada", customerPhone: "771234567", vehicleReg: "ABC-1234", vehicleModel: "CBR", lines: [{ description: "Labour", qty: 1, unitPrice: 500, lineTotal: 500 }], subtotal: 500, total: 500, paymentMethod: "cash", }); try { await storePublishedBill(token, body); assert.equal(await readBillPayload(token), body); const [byId] = await getPool().query("SELECT id FROM invoices WHERE id = ?", [token]); assert.equal(byId.length, 0); await pushChanges({ invoices: [ { id: invoiceId, number: "INV-0001", public_token: token, job_card_id: null, customer_id: null, kind: "job", subtotal: 500, tax_percent: 0, tax_amount: 0, total: 500, snapshot_json: "{}", publish_status: "pending", created_at: t1, updated_at: t1, deleted_at: null, }, ], }); assert.equal(await readBillPayload(token), "{}"); await storePublishedBill(token, body); assert.equal(await readBillPayload(token), body); const [rows] = await getPool().query( "SELECT id, snapshot_json, updated_at FROM invoices WHERE public_token = ?", [token], ); assert.equal(rows.length, 1); assert.equal(rows[0].id, invoiceId); assert.equal(rows[0].snapshot_json, body); const updated = rows[0].updated_at instanceof Date ? rows[0].updated_at.toISOString() : String(rows[0].updated_at).replace(" ", "T").replace(/Z?$/, "Z"); assert.equal(updated.startsWith("2026-09-28T06:30:00"), true); } finally { try { await getPool().query("DELETE FROM bill_snapshots WHERE token = ?", [token]); } catch { // no DELETE grant } await remove("invoices", invoiceId); } }); test("status lookup stays on one customer", { skip: !hasDb }, async () => { await ensureSchema(); const t = "2026-09-28T06:30:00.000Z"; const customerId = randomUUID(); const otherId = randomUUID(); const vehicleId = randomUUID(); const billedVehicleId = randomUUID(); const otherVehicleId = randomUUID(); const jobId = randomUUID(); const billedJobId = randomUUID(); const eventId = randomUUID(); const vehicle = (id: string, customer: string, registration: string) => ({ id, customer_id: customer, registration, vin: "", make: "Honda", model_name: "CBR", year: null, engine_capacity: null, bike_model_id: null, created_at: t, updated_at: t, deleted_at: null, }); try { await pushChanges({ customers: [ { id: customerId, phone: "0771234567", name: "Ada", created_at: t, updated_at: t, deleted_at: null }, { id: otherId, phone: "779999999", name: "Bea", created_at: t, updated_at: t, deleted_at: null }, ], vehicles: [ vehicle(vehicleId, customerId, "ABC-1234"), vehicle(billedVehicleId, customerId, "OLD-1"), vehicle(otherVehicleId, otherId, "SECRET-999"), ], job_cards: [ { id: jobId, vehicle_id: vehicleId, customer_id: customerId, brought_by_customer_id: null, complaint: "DO NOT LEAK COMPLAINT", stage: "in_repair", engineer_notes: "DO NOT LEAK NOTES", created_at: t, updated_at: t, deleted_at: null, }, { id: billedJobId, vehicle_id: billedVehicleId, customer_id: customerId, brought_by_customer_id: null, complaint: "DO NOT LEAK COMPLAINT", stage: "billed", engineer_notes: "DO NOT LEAK NOTES", created_at: t, updated_at: t, deleted_at: null, }, ], job_card_events: [ { id: eventId, job_card_id: jobId, stage: "in_repair", note: "DO NOT LEAK EVENT NOTE", at: t, updated_at: t, deleted_at: null, }, ], }); const result = await lookupBikes("077 123 4567"); assert.equal(result.error, null); assert.equal(result.message, null); assert.equal(result.bikes.length, 2); assert.equal(result.bikes[0].registration, "ABC-1234"); assert.equal(result.bikes[0].make, "Honda"); assert.equal(result.bikes[0].model, "CBR"); assert.equal(result.bikes[0].inWorkshop, true); assert.equal(result.bikes[0].stageLabel, "In Repair"); assert.equal(result.bikes[0].stageAt, t); assert.equal(result.bikes[1].registration, "OLD-1"); assert.equal(result.bikes[1].inWorkshop, false); const dumped = JSON.stringify(result); assert.equal(dumped.includes("SECRET-999"), false); assert.equal(dumped.includes("DO NOT LEAK"), false); assert.equal(dumped.includes("Bea"), false); assert.equal(dumped.includes("pin_hash"), false); assert.equal(dumped.includes(NOT_IN_WORKSHOP), false); const unknown = await lookupBikes("0700000000"); assert.equal(unknown.message, NO_BIKE); assert.deepEqual(unknown.bikes, []); assert.equal(JSON.stringify(unknown).includes("ABC-1234"), false); } finally { for (const [table, id] of [ ["job_card_events", eventId], ["job_cards", jobId], ["job_cards", billedJobId], ["vehicles", vehicleId], ["vehicles", billedVehicleId], ["vehicles", otherVehicleId], ["customers", customerId], ["customers", otherId], ] as const) { await remove(table, id); } } });