WFDASH-WEBAPP/src/lib/server/travel-history-parser.test.ts
Dave 7555196166 feat(audit): implement audit trail for delivery notes and orders
- 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.
2026-09-30 01:48:44 +02:00

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