c7c83890e4
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
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.
211 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
}); |