WFDASH-WEBAPP/src/lib/server/visits-service.test.ts
Dave c97b2cf018 fix(visits): surface batch write failures instead of a silent 200
Found by driving /rep in a real browser session, not by a unit test:
submitting a check-in returned 200 OK with body {"created":0,"pinned":1},
and the visit vanished with no trace while the customer coordinate pin
still landed. Reproduced against the live database (confirmed via
direct PocketBase query) and again after the fix, with the response
now 502 and the entry correctly retained in the outbox.

Root cause was two-layered:

1. visits-service.ts caught every per-visit write error and continued
   silently, discarding the message. A per-item failure (one stale
   customer id in a batch) is meant to be swallowed so it doesn't cost
   the other visits — but the same catch also swallowed a systemic
   failure (every item in the batch hitting an identical PB validation
   error), which looks completely different: a bare `created: 0` gives
   the caller no way to tell "nothing to do" from "everything failed".
   recordVisits now returns an `errors: {customerId, message}[]` array
   instead of discarding the exception.

2. POST /api/v1/visits returned HTTP 200 whenever the request was
   well-formed, regardless of whether anything was actually written.
   The offline outbox treats any 2xx as delivered and discards the
   entry — so a total write failure was indistinguishable from success
   at the one layer that decides whether to keep retrying. The route
   now returns 502 when `created === 0`, which the outbox already
   retries (see the existing 5xx-keeps-the-entry test), rather than a
   4xx, which it treats as permanent and drops.

The concrete trigger was live: rep_visits.position_source doesn't yet
allow "phone" (flagged in the previous commit). That specific gap will
close once it's added to the PB select, but this fix stands on its own
— any future rejected field would have hit the identical silent-loss
path.

12 new tests (service: captured errors on per-item and total failure;
route: 502 vs 200 boundary, repId taken from the session not the body;
outbox: a 502 keeps the entry queued rather than treating it as sent).

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

303 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const setCustomerCoordinates = vi.fn();
vi.mock("./customers-service", () => ({
setCustomerCoordinates: (...args: unknown[]) => setCustomerCoordinates(...args),
}));
import { getCustomerSnapshot, recordVisits } from "./visits-service";
type Row = Record<string, unknown> & { id: string };
function fakePb(seed: Record<string, Row[]> = {}) {
const store: Record<string, Row[]> = { customers: [], rep_visits: [], ...seed };
let nextId = 1;
const failures: Record<string, Set<string>> = {};
const api = {
store,
/** Makes `create`/`update` on a collection reject for a given id. */
failOn(collection: string, id: string) {
(failures[collection] ??= new Set()).add(id);
},
collection(name: string) {
const rows = () => (store[name] ??= []);
return {
async getFullList<T>(): Promise<T[]> {
return rows() as unknown as T[];
},
async create(data: Record<string, unknown>) {
const customer = String(data.customer ?? "");
if (failures[name]?.has(customer)) throw new Error("create failed");
const row = { ...data, id: `v-${nextId++}` } as Row;
rows().push(row);
return row;
},
async update(id: string, data: Record<string, unknown>) {
if (failures[name]?.has(id)) throw new Error("update failed");
const row = rows().find((r) => r.id === id);
if (!row) throw new Error("not found");
Object.assign(row, data);
return row;
},
};
},
};
return api as unknown as Awaited<
ReturnType<typeof import("./pocketbase-admin").getAdminPocketBase>
> & { store: Record<string, Row[]>; failOn: (c: string, id: string) => void };
}
beforeEach(() => {
vi.clearAllMocks();
setCustomerCoordinates.mockResolvedValue({ id: "c1" });
});
describe("getCustomerSnapshot", () => {
it("flattens the company name and carries real coordinates", async () => {
const pb = fakePb({
customers: [
{
id: "c1",
customer: "DANIE GREYLING",
rep: "u-tiaan",
coordinates: { lat: -26.4, lon: 27.4 },
expand: { company: { company: "Sibanye - Driefontein" } },
},
] as Row[],
});
expect(await getCustomerSnapshot(pb)).toEqual([
{
id: "c1",
customer: "DANIE GREYLING",
companyName: "Sibanye - Driefontein",
rep: "u-tiaan",
lat: -26.4,
lon: 27.4,
},
]);
});
it("omits coordinates for an unpinned customer rather than sending Null Island", async () => {
// Every customer starts at PB's `{lon: 0, lat: 0}` default. Shipping
// that as a position would put the whole customer base in the Gulf
// of Guinea and make the nearby list nonsense.
const pb = fakePb({
customers: [
{ id: "c1", customer: "A", rep: "", coordinates: { lat: 0, lon: 0 } },
{ id: "c2", customer: "B", rep: "" },
] as Row[],
});
const snapshot = await getCustomerSnapshot(pb);
expect(snapshot[0]).not.toHaveProperty("lat");
expect(snapshot[1]).not.toHaveProperty("lon");
});
it("still includes unpinned customers — the search box needs them", async () => {
// On day one *every* customer is unpinned. Filtering them out would
// make the app useless until someone had already seeded the map.
const pb = fakePb({
customers: [{ id: "c1", customer: "A", coordinates: { lat: 0, lon: 0 } }] as Row[],
});
expect(await getCustomerSnapshot(pb)).toHaveLength(1);
});
it("tolerates a missing company expand", async () => {
const pb = fakePb({ customers: [{ id: "c1", customer: "A" }] as Row[] });
const [row] = await getCustomerSnapshot(pb);
expect(row.companyName).toBeNull();
expect(row.rep).toBe("");
});
});
describe("recordVisits", () => {
const base = {
repId: "u-tiaan",
lat: -26.4,
lon: 27.4,
positionSource: "phone" as const,
visitedAt: "2026-09-06T09:15:00.000Z",
};
it("writes one visit row per customer seen", async () => {
const pb = fakePb({ customers: [{ id: "c1" }, { id: "c2" }] as Row[] });
const result = await recordVisits(
{ ...base, accuracyM: 12, visits: [{ customerId: "c1" }, { customerId: "c2" }] },
pb
);
expect(result.created).toBe(2);
expect(pb.store.rep_visits[0]).toMatchObject({
rep: "u-tiaan",
customer: "c1",
visited_at: "2026-09-06T09:15:00.000Z",
coordinates: { lat: -26.4, lon: 27.4 },
position_source: "phone",
accuracy_m: 12,
pinned_customer: false,
});
});
it("stamps coordinates onto a customer being pinned", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
const result = await recordVisits(
{ ...base, visits: [{ customerId: "c1", pinCustomer: true }] },
pb
);
expect(setCustomerCoordinates).toHaveBeenCalledWith("c1", -26.4, 27.4, pb);
expect(result.pinned).toBe(1);
expect(pb.store.rep_visits[0].pinned_customer).toBe(true);
});
it("does not claim a pin that failed to write", async () => {
// A visit row saying `pinned_customer: true` when nothing was
// written would be a lie in the audit trail.
setCustomerCoordinates.mockRejectedValue(new Error("PB down"));
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
const result = await recordVisits(
{ ...base, visits: [{ customerId: "c1", pinCustomer: true }] },
pb
);
expect(result.created).toBe(1);
expect(result.pinned).toBe(0);
expect(pb.store.rep_visits[0].pinned_customer).toBe(false);
});
it("keeps the other visits when one customer fails", async () => {
// Reps submit from a replayed offline queue; losing four good visits
// because one customer id went stale would be the worst outcome.
const pb = fakePb({ customers: [{ id: "c1" }, { id: "c2" }] as Row[] });
pb.failOn("rep_visits", "c1");
const result = await recordVisits(
{ ...base, visits: [{ customerId: "c1" }, { customerId: "c2" }] },
pb
);
expect(result.created).toBe(1);
expect(pb.store.rep_visits[0].customer).toBe("c2");
});
it("reports the failure reason rather than swallowing it", async () => {
// Live-verified failure mode: a `rep_visits.position_source` value
// PocketBase hasn't been given yet fails every item in the batch
// identically. A bare `created: 0` with no explanation looks
// indistinguishable from "nothing to do" — the actual cause has to
// survive the catch.
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
pb.failOn("rep_visits", "c1");
const result = await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
expect(result.errors).toEqual([{ customerId: "c1", message: "create failed" }]);
});
it("reports one error per failed customer in a mixed batch", async () => {
const pb = fakePb({ customers: [{ id: "c1" }, { id: "c2" }] as Row[] });
pb.failOn("rep_visits", "c1");
const result = await recordVisits(
{ ...base, visits: [{ customerId: "c1" }, { customerId: "c2" }] },
pb
);
expect(result.errors).toEqual([{ customerId: "c1", message: "create failed" }]);
expect(result.created).toBe(1);
});
it("returns no errors on a clean run", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
const result = await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
expect(result.errors).toEqual([]);
});
it("updates lastvisiteddate on the customer", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
expect(pb.store.customers[0].lastvisiteddate).toBe("2026-09-06T09:15:00.000Z");
});
it("still records the visit when lastvisiteddate cannot be updated", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
pb.failOn("customers", "c1");
const result = await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
expect(result.created).toBe(1);
});
it("carries notes through", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits(
{ ...base, visits: [{ customerId: "c1", notes: "Quoted on hex nuts" }] },
pb
);
expect(pb.store.rep_visits[0].notes).toBe("Quoted on hex nuts");
});
it("records which position source was used", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits(
{ ...base, positionSource: "tracker", visits: [{ customerId: "c1" }] },
pb
);
expect(pb.store.rep_visits[0].position_source).toBe("tracker");
});
it("skips blank customer ids without failing the batch", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
const result = await recordVisits(
{ ...base, visits: [{ customerId: "" }, { customerId: "c1" }] },
pb
);
expect(result.created).toBe(1);
});
it("rejects a submission with no rep, no customers, or a bad position", async () => {
const pb = fakePb();
await expect(
recordVisits({ ...base, repId: "", visits: [{ customerId: "c1" }] }, pb)
).rejects.toThrow(/repId/);
await expect(recordVisits({ ...base, visits: [] }, pb)).rejects.toThrow(/at least one/);
await expect(
recordVisits({ ...base, lat: 99, visits: [{ customerId: "c1" }] }, pb)
).rejects.toThrow(/lat out of range/);
await expect(
recordVisits({ ...base, lon: NaN, visits: [{ customerId: "c1" }] }, pb)
).rejects.toThrow(/lon out of range/);
});
it("defaults visitedAt to now when the caller omits it", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits(
{ repId: "u", lat: -26.4, lon: 27.4, positionSource: "phone", visits: [{ customerId: "c1" }] },
pb
);
const at = new Date(String(pb.store.rep_visits[0].visited_at)).getTime();
expect(Math.abs(Date.now() - at)).toBeLessThan(5000);
});
});