WFDASH-WEBAPP/src/lib/server/tracker-service.test.ts
Dave 6fd11f4af0 feat(rep-visits): FleetAI tracker adapter, nearby-customer matching, stop detection
Groundwork for the rep visit check-in feature: a rep taps "I'm on site",
the vehicle's tracker position surfaces which customers are there, and
the visit is logged against them.

Pure/unit-testable layers only — no PocketBase writes and no UI yet,
both of which are blocked on schema that has to be added in the PB
admin first. Design doc: docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md

- lib/visit-matching.ts   rankNearbyCustomers: radius match around a
                          point, rep's own customers first
- lib/visit-stops.ts      detectStops: breadcrumb trail -> stops.
                          Anchor-based clustering rather than a running
                          centroid, which would let a slow crawl chain
                          into one bogus stationary stop
- lib/tracker-parsing.ts  Netstar FleetAI response decoding
- lib/server/tracker-service.ts
                          FleetAiAdapter + FakeTrackerAdapter behind a
                          TrackerAdapter seam. The fake is auto-selected
                          when no credentials are configured, so the rest
                          of the feature builds and tests without vendor
                          access.
- lib/geo.ts              isRealGeoPoint, lifting the Null-Island guard
                          that was inlined identically in customers/[id]
                          and companies/[id]

Three FleetAI conventions drive real code here and are each pinned by a
test: errors arrive as HTTP 200 with a failure envelope; "--" means
"unavailable" and is not null (and for IGN, not false); timestamps are
platform-local UTC+2 with no offset marker, so parsing them naively puts
every fresh fix two hours in the past.

FleetAI has no position-history endpoint, so the design polls
getTokenBaseLiveData (whole fleet in one call) and keeps its own trail.
scripts/fleetai-probe.mjs is a throwaway read-only probe to confirm the
three account-shaped assumptions before more is built on top.

102 tests pass. The 9 pre-existing failing test files elsewhere in the
suite were verified identical on a stashed tree and are unrelated.

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

464 lines
16 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
FleetAiAdapter,
FakeTrackerAdapter,
TrackerError,
isTokenError,
parseRetryAfterSeconds,
readFleetAiConfig,
type FleetAiConfig,
} from "./tracker-service";
import { SAST_OFFSET_MINUTES } from "@/lib/tracker-parsing";
const CONFIG: FleetAiConfig = {
baseUrl: "https://fleetai.netstar.co.za",
username: "wfsales",
password: "secret",
projectId: "37",
companyName: "WF Sales",
timezoneOffsetMinutes: SAST_OFFSET_MINUTES,
};
/** Builds a fetch stub that replays the given JSON bodies in order. */
function fetchStub(bodies: unknown[]) {
const calls: { url: string; init: RequestInit }[] = [];
const impl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
const body = bodies[calls.length - 1];
return new Response(JSON.stringify(body), { status: 200 });
});
return { impl: impl as unknown as typeof fetch, calls };
}
const TOKEN_OK = { token: "tok-123" };
describe("FleetAiAdapter — authentication", () => {
it("sends credentials to generateAccessToken and reuses the token", async () => {
const { impl, calls } = fetchStub([
TOKEN_OK,
{ data: [{ plate_no: "ABC123GP", imei_no: "111", device_name: "Bakkie" }] },
{ data: [{ plate_no: "ABC123GP", imei_no: "111", device_name: "Bakkie" }] },
]);
const adapter = new FleetAiAdapter(CONFIG, impl);
await adapter.listVehicles();
await adapter.listVehicles();
// 1 auth + 2 data calls — the token was cached, not re-fetched.
expect(calls).toHaveLength(3);
expect(calls[0].url).toContain("token=generateAccessToken");
expect(JSON.parse(calls[0].init.body as string)).toEqual({
username: "wfsales",
password: "secret",
});
});
it("passes the token as an auth-code header, not a bearer", async () => {
const { impl, calls } = fetchStub([TOKEN_OK, { data: [] }]);
await new FleetAiAdapter(CONFIG, impl).listVehicles();
const headers = calls[1].init.headers as Record<string, string>;
expect(headers["auth-code"]).toBe("tok-123");
expect(headers.Authorization).toBeUndefined();
});
it("explains the Company-account requirement when no token comes back", async () => {
// Admin and Reseller logins are rejected by FleetAI (§1.1) and this
// is the failure an operator is most likely to hit on first setup.
const { impl } = fetchStub([{ message: "incorrect user name and password" }]);
const adapter = new FleetAiAdapter(CONFIG, impl);
await expect(adapter.listVehicles()).rejects.toThrow(/incorrect user name/i);
});
it("re-authenticates once and replays when the token has expired", async () => {
const { impl, calls } = fetchStub([
TOKEN_OK,
{ message: "Refresh Token Expired" },
{ token: "tok-456" },
{ data: [{ plate_no: "ABC123GP" }] },
]);
const adapter = new FleetAiAdapter(CONFIG, impl);
const vehicles = await adapter.listVehicles();
expect(vehicles).toEqual([
{ vehicleNo: "ABC123GP", imei: null, deviceName: null },
]);
expect(calls).toHaveLength(4);
expect((calls[3].init.headers as Record<string, string>)["auth-code"]).toBe("tok-456");
});
it("gives up after one retry rather than looping", async () => {
const { impl, calls } = fetchStub([
TOKEN_OK,
{ message: "Invalid Token" },
{ token: "tok-456" },
{ message: "Invalid Token" },
]);
const adapter = new FleetAiAdapter(CONFIG, impl);
await expect(adapter.listVehicles()).rejects.toThrow(/Invalid Token/);
expect(calls).toHaveLength(4);
});
});
describe("FleetAiAdapter — errors arriving as HTTP 200", () => {
it("throws on a failure envelope despite the 200 status", async () => {
const { impl } = fetchStub([
TOKEN_OK,
{ RESULT: "FAIL", MSG: "No Vehicle Found For Given User" },
]);
const adapter = new FleetAiAdapter(CONFIG, impl);
await expect(adapter.getCurrentPosition("ABC123GP")).rejects.toThrow(
/No Vehicle Found For Given User/
);
});
it("surfaces the rate-limit wait time so the rep can be told when to retry", async () => {
const { impl } = fetchStub([
TOKEN_OK,
{ message: "Please wait for 10 Seconds for next API call" },
]);
const adapter = new FleetAiAdapter(CONFIG, impl);
const error = await adapter.getCurrentPosition("ABC123GP").catch((e) => e);
expect(error).toBeInstanceOf(TrackerError);
expect((error as TrackerError).retryAfterSeconds).toBe(10);
});
it("throws a readable error on a non-JSON body", async () => {
const impl = vi.fn(async () =>
new Response("<html>gateway timeout</html>", { status: 200 })
) as unknown as typeof fetch;
await expect(new FleetAiAdapter(CONFIG, impl).listVehicles()).rejects.toThrow(
/non-JSON response/
);
});
it("wraps a network failure rather than leaking the raw fetch error", async () => {
const impl = vi.fn(async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch;
await expect(new FleetAiAdapter(CONFIG, impl).listVehicles()).rejects.toThrow(
/Could not reach FleetAI/
);
});
});
describe("FleetAiAdapter — getCurrentPosition", () => {
it("reads a position and converts the platform-local fix time to UTC", async () => {
const { impl, calls } = fetchStub([
TOKEN_OK,
{
data: {
vehicleNumber: "ABC123GP",
latitude: "-26.348597",
longitude: "27.375136",
location: "Carletonville, Gauteng",
speed: "0",
timestamp: "04-09-2026 14:30:00",
},
},
]);
const position = await new FleetAiAdapter(CONFIG, impl).getCurrentPosition("ABC123GP");
expect(calls[1].url).toContain("token=getVehicleCurrentLocation");
expect(JSON.parse(calls[1].init.body as string)).toEqual({ vehicle_no: "ABC123GP" });
expect(position).toMatchObject({
vehicleNo: "ABC123GP",
lat: -26.348597,
lon: 27.375136,
address: "Carletonville, Gauteng",
speedKmh: 0,
fixedAt: "2026-09-04T12:30:00.000Z",
source: "fleetai",
});
});
it("prefers the GPS fix time over the platform receive time", async () => {
// A vehicle underground reports late: the platform received the row
// at 14:30 but the fix itself is from 14:00. Staleness must reflect
// the fix, otherwise a 30-minute-old position looks fresh.
const { impl } = fetchStub([
TOKEN_OK,
{
data: {
latitude: "-26.3",
longitude: "27.3",
GPSActualTime: "04-09-2026 14:00:00",
timestamp: "04-09-2026 14:30:00",
},
},
]);
const position = await new FleetAiAdapter(CONFIG, impl).getCurrentPosition("ABC123GP");
expect(position?.fixedAt).toBe("2026-09-04T12:00:00.000Z");
});
it("handles the double-dash sentinel in optional fields", async () => {
const { impl } = fetchStub([
TOKEN_OK,
{
data: {
latitude: "-26.3",
longitude: "27.3",
location: "--",
speed: "--",
timestamp: "--",
},
},
]);
const position = await new FleetAiAdapter(CONFIG, impl).getCurrentPosition("ABC123GP");
expect(position?.address).toBeNull();
expect(position?.speedKmh).toBeNull();
expect(position?.fixedAt).toBeNull();
expect(position?.fixAgeSeconds).toBeNull();
});
it("returns null when the vehicle has no position fix", async () => {
const { impl } = fetchStub([TOKEN_OK, { data: [] }]);
const position = await new FleetAiAdapter(CONFIG, impl).getCurrentPosition("ABC123GP");
expect(position).toBeNull();
});
it("returns null rather than a (0,0) position when coordinates are unavailable", async () => {
// Null Island again — a position of "--" must not become 0,0, which
// would silently match nothing and look like "no customers here".
const { impl } = fetchStub([
TOKEN_OK,
{ data: { latitude: "--", longitude: "--", timestamp: "04-09-2026 14:30:00" } },
]);
const position = await new FleetAiAdapter(CONFIG, impl).getCurrentPosition("ABC123GP");
expect(position).toBeNull();
});
it("reads the root.VehicleData envelope as well as data", async () => {
const { impl } = fetchStub([
TOKEN_OK,
{ root: { VehicleData: [{ Latitude: "-26.3", Longitude: "27.3" }] } },
]);
const position = await new FleetAiAdapter(CONFIG, impl).getCurrentPosition("ABC123GP");
expect(position).toMatchObject({ lat: -26.3, lon: 27.3 });
});
});
describe("FleetAiAdapter — listVehicles", () => {
it("maps plate_no to vehicleNo and skips rows with no registration", async () => {
const { impl } = fetchStub([
TOKEN_OK,
{
data: [
{ plate_no: "ABC123GP", imei_no: "111", device_name: "Chris bakkie" },
{ plate_no: "--", imei_no: "222" },
{ imei_no: "333" },
],
},
]);
const vehicles = await new FleetAiAdapter(CONFIG, impl).listVehicles();
expect(vehicles).toEqual([
{ vehicleNo: "ABC123GP", imei: "111", deviceName: "Chris bakkie" },
]);
});
it("sends the configured project id", async () => {
const { impl, calls } = fetchStub([TOKEN_OK, { data: [] }]);
await new FleetAiAdapter({ ...CONFIG, projectId: "42" }, impl).listVehicles();
expect(JSON.parse(calls[1].init.body as string)).toEqual({ project_id: "42" });
});
});
describe("FleetAiAdapter — getFleetPositions (the breadcrumb poller)", () => {
const FLEET_BODY = {
root: {
VehicleData: [
{
Vehicle_No: "ABC123GP",
Imeino: "111",
Latitude: "-26.348597",
Longitude: "27.375136",
Speed: "0",
Status: "STOP",
IGN: "OFF",
Odometer: "184203",
Location: "Driefontein, Carletonville",
GPSActualTime: "04-09-2026 09:15:00",
},
{
Vehicle_No: "XYZ789GP",
Imeino: "222",
Latitude: "-26.2041",
Longitude: "28.0473",
Speed: "62",
Status: "RUNNING",
IGN: "ON",
Odometer: "97110",
GPSActualTime: "04-09-2026 09:14:30",
},
],
},
};
it("fetches the whole fleet in a single call", async () => {
const { impl, calls } = fetchStub([TOKEN_OK, FLEET_BODY]);
const positions = await new FleetAiAdapter(CONFIG, impl).getFleetPositions();
// One auth + exactly one data call for two vehicles — this is the
// whole reason the poller uses this endpoint over the per-vehicle one.
expect(calls).toHaveLength(2);
expect(positions).toHaveLength(2);
expect(calls[1].url).toContain("token=getTokenBaseLiveData");
expect(calls[1].url).toContain("ProjectId=37");
});
it("sends the company name and trims the payload to the fields we store", async () => {
const { impl, calls } = fetchStub([TOKEN_OK, FLEET_BODY]);
await new FleetAiAdapter(CONFIG, impl).getFleetPositions();
const body = JSON.parse(calls[1].init.body as string);
expect(body.company_names).toBe("WF Sales");
expect(body.format).toBe("json");
expect(body.parameters).toContain("GPSActualTime");
expect(body.parameters).toContain("IGN");
// No imei_nos → every vehicle on the account.
expect(body.imei_nos).toBeUndefined();
});
it("filters to specific IMEIs when asked", async () => {
const { impl, calls } = fetchStub([TOKEN_OK, FLEET_BODY]);
await new FleetAiAdapter(CONFIG, impl).getFleetPositions(["111", "222"]);
expect(JSON.parse(calls[1].init.body as string).imei_nos).toBe("111,222");
});
it("reads ignition, status and odometer — the fields stop detection needs", async () => {
const { impl } = fetchStub([TOKEN_OK, FLEET_BODY]);
const positions = await new FleetAiAdapter(CONFIG, impl).getFleetPositions();
expect(positions[0]).toMatchObject({
vehicleNo: "ABC123GP",
imei: "111",
status: "STOP",
ignition: false,
odometerKm: 184203,
speedKmh: 0,
fixedAt: "2026-09-04T07:15:00.000Z",
});
expect(positions[1]).toMatchObject({
status: "RUNNING",
ignition: true,
speedKmh: 62,
});
});
it("skips a vehicle with no fix instead of failing the whole poll", async () => {
// One dead tracker must not cost us the trail for every other rep.
const { impl } = fetchStub([
TOKEN_OK,
{
root: {
VehicleData: [
{ Vehicle_No: "DEAD1GP", Latitude: "--", Longitude: "--" },
{ Vehicle_No: "OK123GP", Latitude: "-26.3", Longitude: "27.3" },
],
},
},
]);
const positions = await new FleetAiAdapter(CONFIG, impl).getFleetPositions();
expect(positions.map((p) => p.vehicleNo)).toEqual(["OK123GP"]);
});
it("short-circuits on an empty IMEI list without calling the API", async () => {
const { impl, calls } = fetchStub([]);
expect(await new FleetAiAdapter(CONFIG, impl).getFleetPositions([])).toEqual([]);
expect(calls).toHaveLength(0);
});
it("fails loudly when the company name is not configured", async () => {
// FleetAI answers a wrong/missing company with "No Company Found For
// Given User", which would otherwise look like "no vehicles today".
const { impl } = fetchStub([TOKEN_OK]);
const adapter = new FleetAiAdapter({ ...CONFIG, companyName: null }, impl);
await expect(adapter.getFleetPositions()).rejects.toThrow(/FLEETAI_COMPANY_NAME/);
});
});
describe("FakeTrackerAdapter", () => {
it("parks a vehicle on the West Rand so distance maths behaves realistically", async () => {
const position = await new FakeTrackerAdapter().getCurrentPosition("FAKE-001-GP");
expect(position?.lat).toBeCloseTo(-26.348597, 5);
expect(position?.lon).toBeCloseTo(27.375136, 5);
expect(position?.source).toBe("fake");
});
it("returns null for a vehicle it doesn't know", async () => {
expect(await new FakeTrackerAdapter().getCurrentPosition("NOPE")).toBeNull();
});
it("can simulate a stale fix", async () => {
const adapter = new FakeTrackerAdapter({ fixAgeSeconds: 3600 });
const position = await adapter.getCurrentPosition("FAKE-001-GP");
expect(position?.fixAgeSeconds).toBe(3600);
});
});
describe("readFleetAiConfig", () => {
it("returns null when credentials are absent so the fake can take over", () => {
expect(readFleetAiConfig({})).toBeNull();
});
it("defaults the base URL, project id and SAST offset", () => {
const config = readFleetAiConfig({
FLEETAI_USERNAME: "u",
FLEETAI_PASSWORD: "p",
});
expect(config).toEqual({
baseUrl: "https://fleetai.netstar.co.za",
username: "u",
password: "p",
projectId: "37",
// Only the fleet-wide poll needs this; check-in works without it.
companyName: null,
timezoneOffsetMinutes: 120,
});
});
it("allows the timezone offset to be overridden", () => {
const config = readFleetAiConfig({
FLEETAI_USERNAME: "u",
FLEETAI_PASSWORD: "p",
FLEETAI_TZ_OFFSET_MINUTES: "0",
});
expect(config?.timezoneOffsetMinutes).toBe(0);
});
});
describe("error classification helpers", () => {
it("recognises the documented token errors", () => {
expect(isTokenError("Refresh Token Expired")).toBe(true);
expect(isTokenError("Invalid Token")).toBe(true);
expect(isTokenError("No Vehicle Found For Given User")).toBe(false);
});
it("reads the wait time out of each documented rate-limit phrasing", () => {
expect(parseRetryAfterSeconds("Please wait for 10 Seconds for next API call")).toBe(10);
expect(parseRetryAfterSeconds("Please wait for 3 minutes for next API call")).toBe(180);
expect(parseRetryAfterSeconds("Please wait for 5 Minutes for next API call")).toBe(300);
expect(parseRetryAfterSeconds("The call exceeded the limit of one minute one call")).toBe(60);
expect(parseRetryAfterSeconds("Invalid parameter")).toBeNull();
});
});