WFDASH-WEBAPP/src/lib/server/vehicle-trail-service.test.ts
Dave a551a71277 feat(tracker): breadcrumb poller — WFDASH's own vehicle position history
FleetAI has no position-history endpoint, so to answer "where was this
rep's vehicle at 10:20 last Tuesday" we have to record it ourselves.
An n8n Schedule Trigger calls POST /api/v1/tracker/poll every 5 minutes
during working hours; a nightly run derives stops and prunes.

  poll:     */5 6-18 * * 1-5     (the working-hours window lives in the
                                  cron, not in app settings — disabling
                                  the workflow is the off switch)
  nightly:  30 2 * * *

One getTokenBaseLiveData call covers the whole fleet per cycle, and only
genuinely new GPS fixes are written: dedupe keys on (vehicle_no,
fixed_at) against the newest fix already held. A parked tracker keeps
re-reporting the same GPSActualTime, so without this a 5-minute cycle
would store the same row a dozen times an hour.

Raw breadcrumbs are pruned at 35 days; derived rep_stops are kept
indefinitely, since a stop is small and is what reports actually ask
about.

Two bugs found by running this against the real stack, neither of which
the unit tests could have caught:

- FakeTrackerAdapter derived its fix time from Date.now(), so every poll
  looked like new movement. It could never reproduce a parked vehicle —
  the exact case dedupe exists for — and made the poller look correct
  while storing a duplicate every cycle. The fake now quantises its fix
  time to a bucket, so repeated polls inside one window return an
  identical fix.
- PocketBase's filter parser silently matches NOTHING against an
  ISO-8601 `T` separator. `fixed_at >= "2026-09-06T00:00:00.000Z"`
  returned 0 rows where the space-separated form returned 2, with no
  error either way — the rollup would have derived no stops and the
  prune deleted no rows, both reporting success, forever. Fixed with a
  `pbDate()` helper matching the existing convention in
  boss-dashboard-service.ts and order-delivery-note-link-service.ts.

Verified end to end on :4001 against the live PocketBase: three
consecutive polls returned stored=0/duplicates=1, and the nightly rollup
went from vehicles=0 to vehicles=1 once the date format was fixed. Test
rows were removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:08:20 +02:00

394 lines
13 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
pollFleet,
pruneOldPositions,
rollUpStops,
normaliseVehicleNo,
loadVehicleRepMap,
pbDate,
RETENTION_DAYS,
} from "./vehicle-trail-service";
import type { TrackerAdapter, VehiclePosition } from "./tracker-service";
/* ============================================================================
* Fixtures + a minimal PocketBase stand-in
* ========================================================================== */
function position(over: Partial<VehiclePosition> = {}): VehiclePosition {
return {
vehicleNo: "ABC123GP",
imei: "111",
lat: -26.348597,
lon: 27.375136,
address: null,
speedKmh: 0,
status: "STOP",
ignition: false,
odometerKm: null,
fixedAt: "2026-09-05T07:00:00.000Z",
fixAgeSeconds: 30,
source: "fleetai",
raw: {},
...over,
};
}
function adapterReturning(positions: VehiclePosition[]): TrackerAdapter {
return {
listVehicles: vi.fn(async () => []),
getCurrentPosition: vi.fn(async () => null),
getFleetPositions: vi.fn(async () => positions),
};
}
type Row = Record<string, unknown> & { id: string };
/**
* Just enough PocketBase to exercise this service: per-collection row
* stores, `create`/`delete`, and the two read shapes the service uses
* (`getFullList` and a sorted `getFirstListItem`). Filters are evaluated
* only for the specific predicates the service emits, which keeps the
* fake honest without reimplementing PB's filter language.
*/
function fakePb(seed: Record<string, Row[]> = {}) {
const store: Record<string, Row[]> = {
users: [],
rep_vehicle_positions: [],
rep_stops: [],
...seed,
};
let nextId = 1;
function matches(row: Row, filter?: string): boolean {
if (!filter) return true;
return filter.split("&&").every((clause) => {
const c = clause.trim();
const eq = c.match(/^(\w+)\s*=\s*"(.*)"$/);
if (eq) return String(row[eq[1]] ?? "") === eq[2];
const cmp = c.match(/^(\w+)\s*(>=|<)\s*"(.*)"$/);
if (cmp) {
const value = new Date(String(row[cmp[1]] ?? "")).getTime();
const bound = new Date(cmp[3]).getTime();
return cmp[2] === ">=" ? value >= bound : value < bound;
}
throw new Error(`fakePb: unsupported filter clause ${c}`);
});
}
const api = {
store,
collection(name: string) {
const rows = () => (store[name] ??= []);
return {
async getFullList<T>(opts?: { filter?: string; sort?: string }): Promise<T[]> {
let out = rows().filter((r) => matches(r, opts?.filter));
if (opts?.sort === "fixed_at") {
out = [...out].sort(
(a, b) =>
new Date(String(a.fixed_at)).getTime() -
new Date(String(b.fixed_at)).getTime()
);
}
return out as unknown as T[];
},
async getFirstListItem<T>(
filter: string,
opts?: { sort?: string }
): Promise<T> {
let out = rows().filter((r) => matches(r, filter));
if (opts?.sort === "-fixed_at") {
out = [...out].sort(
(a, b) =>
new Date(String(b.fixed_at)).getTime() -
new Date(String(a.fixed_at)).getTime()
);
}
if (out.length === 0) {
const err = new Error("not found") as Error & { status?: number };
err.status = 404;
throw err;
}
return out[0] as unknown as T;
},
async create(data: Record<string, unknown>) {
const row = { ...data, id: `rec-${nextId++}` } as Row;
rows().push(row);
return row;
},
async delete(id: string) {
store[name] = rows().filter((r) => r.id !== id);
return true;
},
};
},
};
return api as unknown as Awaited<
ReturnType<typeof import("./pocketbase-admin").getAdminPocketBase>
> & { store: Record<string, Row[]> };
}
/* ============================================================================
* Tests
* ========================================================================== */
describe("pbDate", () => {
it("uses a space separator, not the ISO T", () => {
// Verified against the live instance: PocketBase's filter parser
// silently matches NOTHING for `fixed_at >= "…T00:00:00.000Z"` and
// matches correctly for the space-separated form. No error either
// way, so the rollup and prune would quietly no-op forever.
expect(pbDate(new Date("2026-09-06T00:00:00.000Z"))).toBe(
"2026-09-06 00:00:00.000Z"
);
expect(pbDate(new Date("2026-09-06T00:00:00.000Z"))).not.toContain("T");
});
});
describe("normaliseVehicleNo", () => {
it("ignores spacing and case so an operator can type it either way", () => {
expect(normaliseVehicleNo("ABC 123 GP")).toBe("ABC123GP");
expect(normaliseVehicleNo("abc123gp")).toBe("ABC123GP");
});
it("returns an empty string for nothing", () => {
expect(normaliseVehicleNo(undefined)).toBe("");
expect(normaliseVehicleNo("")).toBe("");
});
});
describe("loadVehicleRepMap", () => {
it("maps registrations to reps and skips users with no vehicle", async () => {
const pb = fakePb({
users: [
{ id: "u-tiaan", tracker_vehicle_id: "ABC 123 GP" },
{ id: "u-chris", tracker_vehicle_id: "xyz789gp" },
{ id: "u-office", tracker_vehicle_id: "" },
{ id: "u-none" },
] as Row[],
});
const map = await loadVehicleRepMap(pb);
expect(map.get("ABC123GP")).toBe("u-tiaan");
expect(map.get("XYZ789GP")).toBe("u-chris");
expect(map.size).toBe(2);
});
});
describe("pollFleet", () => {
it("stores a new fix and attaches the rep who drives that vehicle", async () => {
const pb = fakePb({ users: [{ id: "u-tiaan", tracker_vehicle_id: "ABC123GP" }] as Row[] });
const result = await pollFleet({ pb, adapter: adapterReturning([position()]) });
expect(result).toMatchObject({ fetched: 1, stored: 1, duplicates: 0 });
expect(pb.store.rep_vehicle_positions[0]).toMatchObject({
rep: "u-tiaan",
vehicle_no: "ABC123GP",
coordinates: { lat: -26.348597, lon: 27.375136 },
fixed_at: "2026-09-05T07:00:00.000Z",
ignition: false,
});
});
it("skips a fix it already holds — the whole point of the dedupe", async () => {
// A parked tracker stops emitting new fixes, so consecutive polls
// return an identical position. Without this, a 5-minute cycle
// would write the same row a dozen times an hour.
const pb = fakePb({ users: [] });
const adapter = adapterReturning([position()]);
const first = await pollFleet({ pb, adapter });
const second = await pollFleet({ pb, adapter });
expect(first.stored).toBe(1);
expect(second).toMatchObject({ fetched: 1, stored: 0, duplicates: 1 });
expect(pb.store.rep_vehicle_positions).toHaveLength(1);
});
it("stores a genuinely newer fix for the same vehicle", async () => {
const pb = fakePb({ users: [] });
await pollFleet({ pb, adapter: adapterReturning([position()]) });
const second = await pollFleet({
pb,
adapter: adapterReturning([position({ fixedAt: "2026-09-05T07:05:00.000Z" })]),
});
expect(second.stored).toBe(1);
expect(pb.store.rep_vehicle_positions).toHaveLength(2);
});
it("does not treat an older fix as new", async () => {
// Clock skew or a retry can hand us a stale fix after a fresh one.
const pb = fakePb({ users: [] });
await pollFleet({ pb, adapter: adapterReturning([position({ fixedAt: "2026-09-05T07:05:00.000Z" })]) });
const second = await pollFleet({ pb, adapter: adapterReturning([position()]) });
expect(second).toMatchObject({ stored: 0, duplicates: 1 });
});
it("stores an unmapped vehicle with a blank rep rather than dropping it", async () => {
// The trail should already exist on the day someone finally fills in
// that rep's tracker_vehicle_id.
const pb = fakePb({ users: [] });
await pollFleet({ pb, adapter: adapterReturning([position()]) });
expect(pb.store.rep_vehicle_positions[0].rep).toBe("");
});
it("drops a fix with no usable timestamp instead of guessing one", async () => {
const pb = fakePb({ users: [] });
const result = await pollFleet({
pb,
adapter: adapterReturning([
position({ fixedAt: null }),
position({ vehicleNo: "XYZ789GP", fixedAt: "not a date" }),
]),
});
expect(result).toMatchObject({ fetched: 2, stored: 0, unusable: 2 });
expect(pb.store.rep_vehicle_positions).toHaveLength(0);
});
it("deduplicates within a single response", async () => {
const pb = fakePb({ users: [] });
const result = await pollFleet({
pb,
adapter: adapterReturning([position(), position()]),
});
expect(result).toMatchObject({ stored: 1, duplicates: 1 });
});
it("handles an empty fleet without touching PocketBase", async () => {
const pb = fakePb({ users: [] });
const result = await pollFleet({ pb, adapter: adapterReturning([]) });
expect(result).toEqual({ fetched: 0, stored: 0, duplicates: 0, unusable: 0 });
});
it("keeps vehicles independent of each other", async () => {
const pb = fakePb({ users: [] });
const adapter = adapterReturning([
position(),
position({ vehicleNo: "XYZ789GP", fixedAt: "2026-09-05T06:00:00.000Z" }),
]);
const result = await pollFleet({ pb, adapter });
expect(result.stored).toBe(2);
});
});
describe("rollUpStops", () => {
/** Builds a stationary trail: n fixes, `stepMinutes` apart. */
function parkedTrail(count: number, stepMinutes: number): Row[] {
return Array.from({ length: count }, (_, i) => ({
id: `p-${i}`,
rep: "u-tiaan",
vehicle_no: "ABC123GP",
coordinates: { lat: -26.4, lon: 27.4 },
fixed_at: new Date(Date.UTC(2026, 8, 5, 7, 0, 0) + i * stepMinutes * 60_000).toISOString(),
speed_kmh: 0,
ignition: false,
}));
}
it("writes a stop for a vehicle parked at one place", async () => {
const pb = fakePb({ rep_vehicle_positions: parkedTrail(4, 5) });
const result = await rollUpStops("2026-09-05", { pb });
expect(result).toEqual({ vehicles: 1, stops: 1 });
expect(pb.store.rep_stops[0]).toMatchObject({
rep: "u-tiaan",
vehicle_no: "ABC123GP",
duration_minutes: 15,
fix_count: 4,
ignition_off: true,
});
});
it("is idempotent — a re-run replaces the day's stops rather than doubling them", async () => {
const pb = fakePb({ rep_vehicle_positions: parkedTrail(4, 5) });
await rollUpStops("2026-09-05", { pb });
await rollUpStops("2026-09-05", { pb });
expect(pb.store.rep_stops).toHaveLength(1);
});
it("ignores positions outside the requested day", async () => {
const pb = fakePb({
rep_vehicle_positions: [
...parkedTrail(4, 5),
{
id: "other-day",
rep: "u-tiaan",
vehicle_no: "ABC123GP",
coordinates: { lat: -26.4, lon: 27.4 },
fixed_at: "2026-09-04T09:00:00.000Z",
ignition: false,
},
] as Row[],
});
const result = await rollUpStops("2026-09-05", { pb });
expect(result.stops).toBe(1);
expect(pb.store.rep_stops[0].fix_count).toBe(4);
});
it("rejects an unparseable date rather than scanning an empty range", async () => {
const pb = fakePb();
await expect(rollUpStops("not-a-date", { pb })).rejects.toThrow(/invalid date/);
});
it("returns zero stops for a day with no trail", async () => {
const pb = fakePb();
expect(await rollUpStops("2026-09-05", { pb })).toEqual({ vehicles: 0, stops: 0 });
});
});
describe("pruneOldPositions", () => {
it("deletes only breadcrumbs past the retention window", async () => {
const now = new Date("2026-09-05T12:00:00.000Z");
const pb = fakePb({
rep_vehicle_positions: [
{ id: "old", vehicle_no: "A", fixed_at: "2026-07-01T00:00:00.000Z" },
{ id: "fresh", vehicle_no: "A", fixed_at: "2026-09-04T00:00:00.000Z" },
] as Row[],
});
const result = await pruneOldPositions(RETENTION_DAYS, { pb, now });
expect(result).toEqual({ deleted: 1 });
expect(pb.store.rep_vehicle_positions.map((r) => r.id)).toEqual(["fresh"]);
});
it("leaves derived stops alone — they are the durable record", async () => {
const now = new Date("2026-09-05T12:00:00.000Z");
const pb = fakePb({
rep_vehicle_positions: [
{ id: "old", vehicle_no: "A", fixed_at: "2026-07-01T00:00:00.000Z" },
] as Row[],
rep_stops: [{ id: "s1", vehicle_no: "A", started_at: "2026-07-01T08:00:00.000Z" }] as Row[],
});
await pruneOldPositions(RETENTION_DAYS, { pb, now });
expect(pb.store.rep_stops).toHaveLength(1);
});
it("defaults to a 35-day window", () => {
expect(RETENTION_DAYS).toBe(35);
});
});