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>
394 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|