- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field. - Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability. - Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications. - Enhanced the `package.json` with a new migration script for setting up the audit log functionality. This feature improves accountability and transparency in the management of delivery notes and orders.
187 lines
8.6 KiB
TypeScript
187 lines
8.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
parseDurationToSeconds,
|
|
parseTravelHistoryCsv,
|
|
parseTrackerTimestampToUtc,
|
|
splitCsvLine,
|
|
} from "./travel-history-parser";
|
|
|
|
/**
|
|
* The real export shape, trimmed. Every quirk these tests pin down is a trap
|
|
* that shipped in the actual file:
|
|
* - 4-line preamble before the header
|
|
* - the header repeating mid-file
|
|
* - `Distance` as a running odometer vs `Last Distance` per row
|
|
* - `DD-MM-YYYY hh:mm:ss AM/PM` local time, 12-hour clock
|
|
* - a 0,0 "no fix" row
|
|
*/
|
|
const CSV = `"Travel History"
|
|
" From 01-09-2026 12:00 AM To 28-09-2026 11:33 AM"
|
|
"Duration","Alert"
|
|
"131:06:58","0.0"
|
|
"Object","Status","Start Time","Start Loc","End Time","End Loc","AVG speed","MAX","Last Distance","Distance","Duration","Running","Idle","Stop","Map","Alert","Coordinates"
|
|
"MW13TCGP-MW13TCGP - TIAAN","Idle","01-09-2026 07:59:10 AM","Lang Street,Oberholzer,West Rand,Carletonville,Gauteng,South Africa","01-09-2026 08:05:46 AM","Lang Street,Oberholzer,West Rand,Carletonville,Gauteng,South Africa","0","0","0.0","0.0","00:06:36","00:00:00","00:06:36","00:00:00","-26.3456304,27.3833792---Lang Street---7015","0","-26.3456304,27.3833792"
|
|
"MW13TCGP-MW13TCGP - TIAAN","Running","01-09-2026 08:05:46 AM","Lang Street,Oberholzer,West Rand,Carletonville,Gauteng,South Africa","01-09-2026 08:10:23 AM","Steyn Street,Oberholzer,West Rand,Carletonville,Gauteng,South Africa","20","47","1.55","1.55","00:04:37","00:04:37","00:06:36","00:00:00","-26.3456304,27.3833792---Lang Street---7015","0","-26.3456304,27.3833792"
|
|
"MW13TCGP-MW13TCGP - TIAAN","Stopped","01-09-2026 09:00:00 PM","Steyn Street,Oberholzer,West Rand,Carletonville,Gauteng,South Africa","01-09-2026 11:28:37 PM","Steyn Street,Oberholzer,West Rand,Carletonville,Gauteng,South Africa","0","0","0.0","3274.89","02:28:37","00:04:37","00:06:36","02:28:37","-26.3488272,27.3751392---Steyn Street---7015","0","-26.3488272,27.3751392"
|
|
"Object","Status","Start Time","Start Loc","End Time","End Loc","AVG speed","MAX","Last Distance","Distance","Duration","Running","Idle","Stop","Map","Alert","Coordinates"
|
|
"MW13TCGP-MW13TCGP - TIAAN","Idle","02-09-2026 12:30:00 PM","Radium Street,Carletonville,West Rand","02-09-2026 01:00:00 PM","Radium Street,Carletonville,West Rand","0","0","0.0","3292.85","00:30:00","00:04:37","00:06:36","02:28:37","-26.3596,27.3949---Radium Street---7015","0","-26.3596,27.3949"
|
|
"MW13TCGP-MW13TCGP - TIAAN","Idle","02-09-2026 01:00:00 PM","No Fix Street","02-09-2026 01:10:00 PM","No Fix Street","0","0","0.0","3292.85","00:10:00","00:04:37","00:06:36","02:28:37","-26.3596,27.3949---x---7015","0","0,0"
|
|
`;
|
|
|
|
describe("splitCsvLine", () => {
|
|
it("keeps commas inside quoted fields", () => {
|
|
expect(splitCsvLine('a,"b,c",d')).toEqual(["a", "b,c", "d"]);
|
|
});
|
|
|
|
it("unescapes doubled quotes", () => {
|
|
expect(splitCsvLine('a,"say ""hi""",c')).toEqual(["a", 'say "hi"', "c"]);
|
|
});
|
|
});
|
|
|
|
describe("parseTrackerTimestampToUtc", () => {
|
|
// SAST is UTC+2, so 07:59:10 local == 05:59:10Z.
|
|
it("shifts local time to UTC (07:30 local is the 05:30Z work-day start)", () => {
|
|
expect(parseTrackerTimestampToUtc("01-09-2026 07:59:10 AM")).toBe(
|
|
"2026-09-01T05:59:10.000Z",
|
|
);
|
|
});
|
|
|
|
it("handles 12-hour PM times without rolling to the next day", () => {
|
|
expect(parseTrackerTimestampToUtc("01-09-2026 09:00:00 PM")).toBe(
|
|
"2026-09-01T19:00:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("treats 12 AM as midnight, not noon", () => {
|
|
expect(parseTrackerTimestampToUtc("01-09-2026 12:00:00 AM")).toBe(
|
|
"2026-08-31T22:00:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("treats 12 PM as noon", () => {
|
|
expect(parseTrackerTimestampToUtc("01-09-2026 12:30:00 PM")).toBe(
|
|
"2026-09-01T10:30:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("accepts a missing seconds field", () => {
|
|
expect(parseTrackerTimestampToUtc("01-09-2026 08:05 AM")).toBe(
|
|
"2026-09-01T06:05:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("returns null for junk rather than throwing", () => {
|
|
expect(parseTrackerTimestampToUtc("Status")).toBeNull();
|
|
expect(parseTrackerTimestampToUtc("")).toBeNull();
|
|
expect(parseTrackerTimestampToUtc("2026-09-01 08:05:00")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseDurationToSeconds", () => {
|
|
it("reads HH:MM:SS", () => {
|
|
expect(parseDurationToSeconds("01:15:10")).toBe(4510);
|
|
});
|
|
|
|
it("reads a >24h running total", () => {
|
|
expect(parseDurationToSeconds("131:06:58")).toBe(472_018);
|
|
});
|
|
|
|
it("is zero for junk", () => {
|
|
expect(parseDurationToSeconds("nope")).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("parseTravelHistoryCsv", () => {
|
|
const parsed = parseTravelHistoryCsv(CSV);
|
|
|
|
it("skips the 4-line preamble and finds the real header", () => {
|
|
expect(parsed.legs).toHaveLength(4);
|
|
expect(parsed.warnings.join(" ")).toContain("preamble");
|
|
});
|
|
|
|
it("drops the header row that repeats mid-file", () => {
|
|
expect(parsed.warnings.join(" ")).toContain("repeated header");
|
|
// No leg has status "Status".
|
|
expect(parsed.legs.every((l) => l.status !== ("Status" as never))).toBe(true);
|
|
});
|
|
|
|
it("uses per-row Last Distance, never the cumulative odometer", () => {
|
|
const running = parsed.legs.find((l) => l.status === "Running")!;
|
|
// The row's own Distance column reads 1.55 and Last Distance also 1.55.
|
|
expect(running.distanceKm).toBe(1.55);
|
|
// The odometer at that point is 1.55 too, but the LAST row's Distance is
|
|
// 3292.85 — summing it would have produced thousands of false km.
|
|
const totals = parsed.legs.reduce((a, l) => a + l.distanceKm, 0);
|
|
expect(totals).toBeLessThan(10);
|
|
});
|
|
|
|
it("drops the 0,0 no-fix row instead of placing it in the ocean", () => {
|
|
expect(parsed.warnings.join(" ")).toContain("no GPS fix");
|
|
expect(parsed.legs.some((l) => l.lat === 0 && l.lon === 0)).toBe(false);
|
|
});
|
|
|
|
it("extracts the vehicle id and rep hint from the Object column", () => {
|
|
expect(parsed.vehicle).toBe("MW13TCGP");
|
|
expect(parsed.repHint).toBe("TIAAN");
|
|
});
|
|
|
|
it("reads the export window as local dates", () => {
|
|
expect(parsed.fromLocal).toBe("2026-09-01");
|
|
expect(parsed.toLocal).toBe("2026-09-28");
|
|
});
|
|
|
|
it("sorts legs chronologically", () => {
|
|
const times = parsed.legs.map((l) => l.startUtc);
|
|
expect([...times].sort()).toEqual(times);
|
|
});
|
|
|
|
it("preserves durations and statuses", () => {
|
|
const stopped = parsed.legs.find((l) => l.status === "Stopped")!;
|
|
expect(stopped.durationSeconds).toBe(2 * 3600 + 28 * 60 + 37);
|
|
});
|
|
|
|
it("rejects a file with no header at all", () => {
|
|
expect(() => parseTravelHistoryCsv("a,b,c\n1,2,3")).toThrow(
|
|
/Not a recognised Travel History export/,
|
|
);
|
|
});
|
|
|
|
it("rejects a file whose every row is unusable", () => {
|
|
const onlyHeaders = `"Travel History"
|
|
"Object","Status","Start Time","Start Loc","End Time","End Loc","AVG speed","MAX","Last Distance","Distance","Duration","Running","Idle","Stop","Map","Alert","Coordinates"
|
|
"Object","Status","Start Time","Start Loc","End Time","End Loc","AVG speed","MAX","Last Distance","Distance","Duration","Running","Idle","Stop","Map","Alert","Coordinates"`;
|
|
expect(() => parseTravelHistoryCsv(onlyHeaders)).toThrow(/No usable rows/);
|
|
});
|
|
});
|
|
|
|
describe("bogus End Time (real-export trap)", () => {
|
|
/**
|
|
* In the real file one "Idle" row is stamped 09:13 -> next day 03:00 (a
|
|
* ~41 hour span) while its own Duration column reads 00:04:39. Trusting End
|
|
* Time inflates the month from 131 h to 347 h.
|
|
*/
|
|
const GLITCH = `"Travel History"
|
|
" From 01-09-2026 12:00 AM To 02-09-2026 12:00 AM"
|
|
"Object","Status","Start Time","Start Loc","End Time","End Loc","AVG speed","MAX","Last Distance","Distance","Duration","Running","Idle","Stop","Map","Alert","Coordinates"
|
|
"MW13TCGP-MW13TCGP - TIAAN","Idle","01-09-2026 11:13:52 AM","Lang Street,Oberholzer","02-09-2026 03:00:00 AM","Lang Street,Oberholzer","0","0","0.0","100.0","00:04:39","0","0","0","x","0","-26.3456,27.3834"
|
|
"MW13TCGP-MW13TCGP - TIAAN","Idle","01-09-2026 11:20:00 AM","Lang Street,Oberholzer","01-09-2026 11:30:00 AM","Lang Street,Oberholzer","0","0","0.0","100.0","00:10:00","0","0","0","x","0","-26.3456,27.3834"`;
|
|
|
|
const parsed = parseTravelHistoryCsv(GLITCH);
|
|
|
|
it("uses the file's Duration, not the reported End Time", () => {
|
|
const first = parsed.legs[0];
|
|
expect(first.durationSeconds).toBe(279);
|
|
const span =
|
|
(new Date(first.endUtc).getTime() - new Date(first.startUtc).getTime()) / 1000;
|
|
expect(span).toBe(279);
|
|
});
|
|
|
|
it("flags the inconsistency", () => {
|
|
expect(parsed.warnings.join(" ")).toContain("inconsistent End Time");
|
|
});
|
|
|
|
it("does not let a glitched row swallow the rest of the day", () => {
|
|
const totalMinutes = parsed.legs.reduce((a, l) => a + l.durationSeconds, 0) / 60;
|
|
expect(totalMinutes).toBeLessThan(20);
|
|
});
|
|
});
|