init
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, { updatedAt: string; id: string }> = {};
|
||||
for (const name of SYNC_TABLE_NAMES) cursors[name] = { ...future };
|
||||
cursors.customers = cursor;
|
||||
return cursors;
|
||||
}
|
||||
|
||||
async function findCustomer(id: string): Promise<Record<string, unknown> | 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<RowDataPacket[]>("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<RowDataPacket[]>(
|
||||
"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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user