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; 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)["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("gateway timeout", { 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); }); it("reports the same fix time across polls, like a parked tracker", async () => { // The poller's dedupe keys on (vehicle, fixedAt). A fake that minted // a fresh timestamp per call would make every cycle look like new // movement and store a duplicate row — which is exactly what // happened against the real database before this was quantised. const adapter = new FakeTrackerAdapter(); const first = await adapter.getFleetPositions(); const second = await adapter.getFleetPositions(); expect(first[0].fixedAt).toBe(second[0].fixedAt); }); it("advances the fix time once per bucket", async () => { const adapter = new FakeTrackerAdapter({ fixBucketMs: 1000 }); const before = (await adapter.getFleetPositions())[0].fixedAt; await new Promise((resolve) => setTimeout(resolve, 1100)); const after = (await adapter.getFleetPositions())[0].fixedAt; expect(after).not.toBe(before); }); }); 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(); }); });