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>
464 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|