This commit is contained in:
2026-09-28 23:15:47 +05:30
commit 605c6a2e58
41 changed files with 4299 additions and 0 deletions
+308
View File
@@ -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);
}
}
});
+210
View File
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { isAuthorized } from "../lib/auth";
import { readLimited } from "../lib/body";
import { incomingWins } from "../lib/conflict";
import { isRowId, isUuid } from "../lib/ids";
import { money } from "../lib/money";
import { normalizePhone } from "../lib/phone";
import { allowRequest, clientIp, resetRateLimit } from "../lib/rate-limit";
import { SYNC_TABLES, SYNC_TABLE_NAMES } from "../lib/schema";
import { EPOCH_ISO, prepareRow, resolveCursors } from "../lib/sync";
import { isoToSql, sqlDateToIso } from "../lib/time";
import { pickWorkshopJob, stageLabel } from "../lib/workshop";
const SPEC_TABLES = [
"staff_members",
"shop_settings",
"work_days",
"attendance_records",
"short_leaves",
"overtime_logs",
"customers",
"vehicles",
"job_cards",
"odo_readings",
"job_card_events",
"part_types",
"bike_models",
"bike_groups",
"bike_group_members",
"catalog_parts",
"catalog_part_models",
"part_variants",
"job_line_items",
"invoices",
"invoice_lines",
"payments",
"demand_logs",
"sms_outbox",
"publish_outbox",
];
test("normalizePhone matches the tablet", () => {
assert.equal(normalizePhone("771234567"), "771234567");
assert.equal(normalizePhone("0771234567"), "0771234567");
assert.equal(normalizePhone("077 123 4567"), "0771234567");
assert.equal(normalizePhone("00771234567"), "0771234567");
assert.equal(normalizePhone("917712345678"), "7712345678");
assert.equal(normalizePhone(""), "");
});
test("money uses Rs and two fraction digits", () => {
assert.equal(money(500), "Rs 500.00");
assert.equal(money(0), "Rs 0.00");
assert.match(money(1500), /^Rs 1,500\.00$/);
});
test("uuid and row ids", () => {
assert.equal(isUuid("3f1c2a90-7b14-4d2e-9c11-6a0b5e8d4f21"), true);
assert.equal(isUuid("default"), false);
assert.equal(isUuid("not-a-uuid"), false);
assert.equal(isRowId("default"), true);
assert.equal(isRowId("3f1c2a90-7b14-4d2e-9c11-6a0b5e8d4f21"), true);
assert.equal(isRowId(""), false);
assert.equal(isRowId("has space"), false);
});
test("conflict rule is last write wins, equal incoming wins", () => {
const older = new Date("2026-09-28T06:30:00.000Z");
const newer = new Date("2026-09-28T07:30:00.000Z");
assert.equal(incomingWins(null, older), true);
assert.equal(incomingWins(older, newer), true);
assert.equal(incomingWins(older, new Date(older.getTime())), true);
assert.equal(incomingWins(newer, older), false);
});
test("timestamps round-trip as UTC", () => {
assert.equal(isoToSql("2026-09-28T06:30:00.000Z"), "2026-09-28 06:30:00.000");
assert.equal(sqlDateToIso("2026-09-28 06:30:00.000"), "2026-09-28T06:30:00.000Z");
assert.equal(isoToSql("not-a-date"), null);
});
test("auth requires the bearer token", () => {
const previous = process.env.SYNC_TOKEN;
process.env.SYNC_TOKEN = "test-secret-value";
try {
assert.equal(isAuthorized("Bearer test-secret-value"), true);
assert.equal(isAuthorized("Bearer wrong-secret-value"), false);
assert.equal(isAuthorized("Bearer test-secret-value extra"), false);
assert.equal(isAuthorized("Basic test-secret-value"), false);
assert.equal(isAuthorized(null), false);
} finally {
if (previous === undefined) delete process.env.SYNC_TOKEN;
else process.env.SYNC_TOKEN = previous;
}
});
test("status rate limit is 10 per minute per address", () => {
resetRateLimit();
const now = 1_000_000;
for (let i = 0; i < 10; i += 1) assert.equal(allowRequest("203.0.113.5", now), true);
assert.equal(allowRequest("203.0.113.5", now), false);
assert.equal(allowRequest("203.0.113.9", now), true);
assert.equal(allowRequest("203.0.113.5", now + 60_000), true);
});
test("client ip is the last forwarded hop", () => {
assert.equal(clientIp("1.2.3.4, 203.0.113.5"), "203.0.113.5");
assert.equal(clientIp(null), "direct");
});
test("readLimited caps the body", async () => {
const small = new Request("http://127.0.0.1/", { method: "POST", body: "abc" });
assert.equal(await readLimited(small, 10), "abc");
const big = new Request("http://127.0.0.1/", { method: "POST", body: "x".repeat(20) });
assert.equal(await readLimited(big, 10), null);
});
test("missing sync cursor is the epoch", () => {
const resolved = resolveCursors(undefined);
assert.equal(resolved.error, undefined);
assert.equal(resolved.cursors.customers.updatedAt.toISOString(), EPOCH_ISO);
assert.equal(resolved.cursors.customers.id, "");
assert.equal(Object.keys(resolved.cursors).length, SPEC_TABLES.length);
const partial = resolveCursors({
customers: { updatedAt: "2026-09-28T06:30:00.000Z", id: "abc" },
});
assert.equal(partial.cursors.customers.id, "abc");
assert.equal(partial.cursors.invoices.updatedAt.toISOString(), EPOCH_ISO);
assert.equal(resolveCursors({ customers: { updatedAt: "nope", id: "" } }).error, "invalid cursor");
});
test("prepareRow stores is_dirty itself and accepts the settings id", () => {
const settings = prepareRow(SYNC_TABLES.shop_settings, {
id: "default",
name: "100kmph",
sms_bill_template: "Bill {url}",
sms_status_template: "Status",
public_base_url: "https://example.test",
updated_at: "2026-09-28T06:30:00.000Z",
is_dirty: true,
});
assert.equal(settings.ok, true);
if (settings.ok) {
assert.equal(settings.row.columns.includes("is_dirty"), false);
assert.equal(settings.row.id, "default");
}
const customer = prepareRow(SYNC_TABLES.customers, {
id: "3f1c2a90-7b14-4d2e-9c11-6a0b5e8d4f21",
phone: "771234567",
updated_at: "2026-09-28T06:30:00.000Z",
pin_hash: "should-not-stick",
});
assert.equal(customer.ok, false);
});
test("workshop job prefers the newest open stage", () => {
const picked = pickWorkshopJob([
{ id: "a", stage: "billed", createdAt: "2026-09-28T08:00:00.000Z" },
{ id: "b", stage: "in_repair", createdAt: "2026-09-28T07:00:00.000Z" },
{ id: "c", stage: "received", createdAt: "2026-09-28T06:00:00.000Z" },
]);
assert.equal(picked?.id, "b");
assert.equal(
pickWorkshopJob([{ id: "a", stage: "billed", createdAt: "2026-09-28T08:00:00.000Z" }]),
null,
);
assert.equal(stageLabel("quality_check"), "Quality Check");
assert.equal(stageLabel("in_repair"), "In Repair");
});
test("schema matches the migration", () => {
const sql = readFileSync("db/001_init.sql", "utf8");
assert.deepEqual(SYNC_TABLE_NAMES, SPEC_TABLES);
assert.equal(sql.includes("FOREIGN KEY"), false);
assert.match(sql, /Two tablets incrementing this will clash/);
assert.match(sql, /CREATE TABLE IF NOT EXISTS bill_snapshots/);
for (const name of SPEC_TABLES) {
const match = sql.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${name} \\(([\\s\\S]*?)\\n\\)`));
assert.ok(match, name);
const body = match[1];
assert.match(body, /updated_at, id/);
for (const column of SYNC_TABLES[name].columns) {
assert.match(body, new RegExp(`\\b${column.name}\\b`));
}
const hasPin = SYNC_TABLES[name].columns.some((column) => column.name === "pin_hash");
assert.equal(hasPin, name === "staff_members");
}
const customers = sql.match(/CREATE TABLE IF NOT EXISTS customers \(([\s\S]*?)\n\)/);
assert.match(customers?.[1] ?? "", /idx_customers_phone \(phone\)/);
const invoices = sql.match(/CREATE TABLE IF NOT EXISTS invoices \(([\s\S]*?)\n\)/);
assert.match(invoices?.[1] ?? "", /uq_invoices_public_token \(public_token\)/);
const jobs = sql.match(/CREATE TABLE IF NOT EXISTS job_cards \(([\s\S]*?)\n\)/);
assert.match(jobs?.[1] ?? "", /idx_job_cards_customer \(customer_id\)/);
assert.match(jobs?.[1] ?? "", /idx_job_cards_vehicle \(vehicle_id\)/);
});
test("public lookup code does not select private columns", () => {
const status = readFileSync("lib/status.ts", "utf8");
for (const word of ["engineer_notes", "complaint", "pin_hash", "sale_price", "unit_price", "qty_on_hand"]) {
assert.equal(status.includes(word), false, word);
}
const sync = readFileSync("lib/sync.ts", "utf8");
assert.equal(sync.includes("console."), false);
});