Files
100kmph_web/web/tests/sync.integration.test.ts
2026-09-28 23:15:47 +05:30

309 lines
9.6 KiB
TypeScript

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);
}
}
});