Files
route-commerce/tests/unit/time-tracking-fleet-route-summary.test.ts
T
Nora c7c83890e4
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
fix(time-tracking): use correct field_workers columns in fleet summary
The /admin/time-tracking overview page crashed on render with
`column "fw.display_name" does not exist`. Cycle 10 unified
`time_tracking_workers` + `water_irrigators` into `field_workers`,
which uses `name` and `pin_hash` (not the legacy `display_name` /
`pin` from `admin_users`). This fix aligns the SQL, row type, and
result mapper with the actual schema.

End-to-end verified against water.tuxedocorn.com with worker PIN 2229:
PIN auth, task picker, clock-in, and clock-out all succeed with no
console errors and no failed HTTP requests.

Includes a regression test that fails on the buggy SQL (4/8 fail) and
passes after the fix (8/8 pass). Covers the column contract, row
projection, empty-result + unauthenticated boundaries, and null
aggregate coercion.
2026-07-06 09:56:13 -06:00

211 lines
6.9 KiB
TypeScript

/**
* Regression tests for `src/actions/time-tracking/route-summary.ts` →
* `getFleetRouteSummary`.
*
* Why this exists: the original implementation referenced
* `fw.display_name` and `fw.pin` in its SQL, but the unified
* `field_workers` table (cycle 10) uses `name` and `pin_hash`. The
* wrong-column reference crashed the `/admin/time-tracking` overview
* page on render with `column "fw.display_name" does not exist`. No
* test caught it because nothing exercised this code path.
*
* These tests mock the bare minimum (admin user + pg pool) and assert:
* 1. The SQL passed to `pool.query` references the correct columns
* (`fw.name`, `fw.pin_hash`) and NOT the legacy names.
* 2. The row mapper projects `r.name` / `r.pin_hash` (not the
* legacy keys).
* 3. Empty result set yields `[]` without crashing.
* 4. Boundary: an unknown date still produces a valid SQL query
* and a typed return shape.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { mockGetAdminUser, mockQuery } = vi.hoisted(() => {
return {
mockGetAdminUser: vi.fn(),
mockQuery: vi.fn(),
};
});
vi.mock("server-only", () => ({}));
vi.mock("@/lib/admin-permissions", () => ({
getAdminUser: mockGetAdminUser,
}));
vi.mock("@/lib/db", () => ({
getPool: () => ({ query: mockQuery }),
}));
// Stub the withBrand call inside `getRouteTimeSummary` so importing the
// module doesn't try to resolve drizzle at test time.
vi.mock("@/db/client", () => ({
withBrand: vi.fn(async (_brandId: string, fn: (db: unknown) => unknown) =>
fn({ select: () => ({ from: () => ({ where: () => ({ limit: () => [] }) }) }) }),
),
}));
// Stub the cross-module call into the admin time-tracking settings.
vi.mock("@/actions/time-tracking", () => ({
getTimeTrackingSettings: vi.fn(async () => null),
}));
// Imported AFTER all mocks are wired.
import { getFleetRouteSummary } from "@/actions/time-tracking/route-summary";
const BRAND_ID = "11111111-2222-3333-4444-555555555555";
const TODAY = "2026-07-06";
beforeEach(() => {
mockGetAdminUser.mockReset();
mockQuery.mockReset();
mockGetAdminUser.mockResolvedValue({ id: "admin-1", role: "platform_admin" });
});
describe("getFleetRouteSummary — SQL column contract", () => {
it("references fw.name and fw.pin_hash (NOT the legacy fw.display_name / fw.pin)", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await getFleetRouteSummary(BRAND_ID, TODAY);
expect(mockQuery).toHaveBeenCalledTimes(1);
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain("fw.name");
expect(sql).toContain("fw.pin_hash");
// The legacy columns must NOT appear anywhere in the query —
// catching them in the SELECT, JOIN, or ORDER BY is the whole
// point of this test.
expect(sql).not.toMatch(/fw\.display_name/);
expect(sql).not.toMatch(/\bfw\.pin\b/);
});
it("queries with the brand id and ISO date bounds (regression: off-by-one date math)", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await getFleetRouteSummary(BRAND_ID, TODAY);
const [, params] = mockQuery.mock.calls[0];
expect(params).toHaveLength(3);
expect(params[0]).toBe(BRAND_ID);
// Lower bound is the requested date at UTC midnight; upper bound
// is the NEXT day at UTC midnight (24-hour window, half-open).
expect(params[1]).toBe(`${TODAY}T00:00:00.000Z`);
const upper = new Date(params[2] as string);
const lower = new Date(params[1] as string);
expect(upper.getTime() - lower.getTime()).toBe(24 * 60 * 60 * 1000);
});
});
describe("getFleetRouteSummary — row projection", () => {
it("maps the row's `name` field onto workerName and falls back when name is null", async () => {
mockQuery.mockResolvedValueOnce({
rows: [
{
id: "worker-1",
name: "Tyler",
pin_hash: "scrypt$abc123$xyz",
active: true,
water_entries: "3",
water_gallons: "120",
worked_minutes: "240",
is_clocked_in: true,
},
],
});
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
expect(rows).toEqual([
{
workerId: "worker-1",
workerName: "Tyler",
active: true,
water: { entryCount: 3, totalGallons: 120 },
time: { workedMinutesToday: 240, isClockedIn: true },
},
]);
});
it("falls back to a 6-char pin_hash fingerprint when name is null (not the legacy pin column)", async () => {
mockQuery.mockResolvedValueOnce({
rows: [
{
id: "worker-2",
name: null,
pin_hash: "scrypt$ABCDEF$rest",
active: false,
water_entries: "0",
water_gallons: null,
worked_minutes: "0",
is_clocked_in: false,
},
],
});
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
expect(rows).toHaveLength(1);
expect(rows[0].workerName).toBe("scrypt");
expect(rows[0].active).toBe(false);
expect(rows[0].water.totalGallons).toBe(0);
expect(rows[0].time.workedMinutesToday).toBe(0);
expect(rows[0].time.isClockedIn).toBe(false);
});
});
describe("getFleetRouteSummary — boundaries + failure modes", () => {
it("returns [] when there are no active workers in the brand", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
expect(rows).toEqual([]);
});
it("returns [] when the caller is unauthenticated (admin gate)", async () => {
mockGetAdminUser.mockResolvedValueOnce(null);
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
expect(rows).toEqual([]);
// Critical: we must not have hit the pool if auth failed.
expect(mockQuery).not.toHaveBeenCalled();
});
it("defaults to today's UTC date when no date argument is supplied", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await getFleetRouteSummary(BRAND_ID);
expect(mockQuery).toHaveBeenCalledTimes(1);
const [, params] = mockQuery.mock.calls[0];
// The bounds must form a one-day window; lower < upper, and
// lower must be UTC midnight on today's date.
const lower = new Date(params[1] as string);
const upper = new Date(params[2] as string);
expect(lower.getUTCHours()).toBe(0);
expect(upper.getTime() - lower.getTime()).toBe(24 * 60 * 60 * 1000);
});
it("coerces null numeric aggregates to 0 (water_gallons / worked_minutes can be null)", async () => {
mockQuery.mockResolvedValueOnce({
rows: [
{
id: "worker-3",
name: "Pat",
pin_hash: "scrypt$XXX",
active: true,
water_entries: "0",
water_gallons: null,
worked_minutes: null,
is_clocked_in: false,
},
],
});
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
expect(rows[0].water.totalGallons).toBe(0);
expect(rows[0].water.entryCount).toBe(0);
expect(rows[0].time.workedMinutesToday).toBe(0);
});
});