init
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user