Files
route-commerce/tests/unit/water-log-reporting.test.ts
T
Tyler 11cd2fd01a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
2026-06-17 11:36:00 -06:00

279 lines
8.8 KiB
TypeScript

/**
* Tests for `src/lib/water-log-reporting.ts` — pure-function utilities
* used by both the admin and field clients. Covers:
* - irrigation season detection (in/out + year-wrapping)
* - entry shaping for the report format
* - filter by date range, headgate, user, submission source
* - CSV encoding (commas, quotes, newlines, unicode)
* - report formatter ("Daily Report" output for SMS/email)
*/
import { describe, it, expect } from "vitest";
import {
isInIrrigationSeason,
shapeWaterLogEntry,
filterWaterLogEntries,
waterLogToCSV,
formatDailyWaterReport,
getDisplayAgeLabel,
getDisplayAgeColor,
type IrrigationSeasonSettings,
type WaterLogReportRow,
} from "@/lib/water-log-reporting";
const ROW = (overrides: Partial<WaterLogReportRow> = {}): WaterLogReportRow => ({
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
user_role: "irrigator",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
...overrides,
});
describe("isInIrrigationSeason", () => {
const season: IrrigationSeasonSettings = {
seasonStartMonth: 3,
seasonStartDay: 15,
seasonEndMonth: 10,
seasonEndDay: 15,
};
// Construct dates in *local* time (year, monthIndex, day) so the test
// is timezone-independent. `new Date("2026-03-15")` would parse as
// UTC midnight, which on a US TZ tester becomes the previous day
// locally and makes boundary assertions brittle.
const localDate = (y: number, m: number, d: number) => new Date(y, m - 1, d);
it("returns true inside the season (May 1)", () => {
expect(isInIrrigationSeason(localDate(2026, 5, 1), season)).toBe(true);
});
it("returns false before the season (Feb 1)", () => {
expect(isInIrrigationSeason(localDate(2026, 2, 1), season)).toBe(false);
});
it("returns false after the season (Dec 1)", () => {
expect(isInIrrigationSeason(localDate(2026, 12, 1), season)).toBe(false);
});
it("returns true on the boundary day (Mar 15)", () => {
expect(isInIrrigationSeason(localDate(2026, 3, 15), season)).toBe(true);
});
it("returns true on the closing boundary (Oct 15)", () => {
expect(isInIrrigationSeason(localDate(2026, 10, 15), season)).toBe(true);
});
it("returns false the day after closing (Oct 16)", () => {
expect(isInIrrigationSeason(localDate(2026, 10, 16), season)).toBe(false);
});
it("handles a season that wraps the year (Nov → Feb)", () => {
const wrap: IrrigationSeasonSettings = {
seasonStartMonth: 11,
seasonStartDay: 1,
seasonEndMonth: 2,
seasonEndDay: 28,
};
expect(isInIrrigationSeason(localDate(2026, 12, 25), wrap)).toBe(true);
expect(isInIrrigationSeason(localDate(2026, 1, 15), wrap)).toBe(true);
expect(isInIrrigationSeason(localDate(2026, 6, 1), wrap)).toBe(false);
});
it("uses default settings (Mar 15 → Oct 15) when none provided", () => {
expect(isInIrrigationSeason(localDate(2026, 4, 1))).toBe(true);
expect(isInIrrigationSeason(localDate(2026, 12, 1))).toBe(false);
});
});
describe("shapeWaterLogEntry", () => {
it("normalizes an entry, providing a default role", () => {
const out = shapeWaterLogEntry({
id: "x",
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
user_role: "water_admin",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
});
expect(out).toEqual({
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
user_role: "water_admin",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
});
});
it("defaults role to 'irrigator' when missing", () => {
const out = shapeWaterLogEntry({
id: "x",
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
});
expect(out.user_role).toBe("irrigator");
});
});
describe("filterWaterLogEntries", () => {
const rows: WaterLogReportRow[] = [
ROW({ logged_at: "2026-06-10T10:00:00.000Z", headgate_name: "A", user_name: "Tyler" }),
ROW({ logged_at: "2026-06-15T10:00:00.000Z", headgate_name: "B", user_name: "Tyler" }),
ROW({ logged_at: "2026-06-20T10:00:00.000Z", headgate_name: "A", user_name: "Sam" }),
];
it("returns everything when no filters", () => {
expect(filterWaterLogEntries(rows, {})).toHaveLength(3);
});
it("filters by dateFrom (inclusive of full day)", () => {
const out = filterWaterLogEntries(rows, { dateFrom: "2026-06-15" });
expect(out).toHaveLength(2);
});
it("filters by dateTo (inclusive of full day)", () => {
const out = filterWaterLogEntries(rows, { dateTo: "2026-06-15" });
expect(out).toHaveLength(2);
});
it("filters by date range", () => {
const out = filterWaterLogEntries(rows, {
dateFrom: "2026-06-15",
dateTo: "2026-06-15",
});
expect(out).toHaveLength(1);
});
it("filters by headgate", () => {
const out = filterWaterLogEntries(rows, { headgateId: "A" });
expect(out).toHaveLength(2);
});
it("filters by user", () => {
const out = filterWaterLogEntries(rows, { userId: "Sam" });
expect(out).toHaveLength(1);
});
it("filters by submission source", () => {
const rows2 = [
ROW({ submitted_via: "field" }),
ROW({ submitted_via: "admin" }),
];
expect(filterWaterLogEntries(rows2, { submittedVia: "admin" })).toHaveLength(1);
});
it("combines multiple filters", () => {
const out = filterWaterLogEntries(rows, {
dateFrom: "2026-06-15",
headgateId: "A",
});
expect(out).toHaveLength(1);
expect(out[0].user_name).toBe("Sam");
});
});
describe("waterLogToCSV", () => {
it("emits a header row + one row per entry", () => {
const csv = waterLogToCSV([
ROW(),
ROW({ measurement: 4.5, notes: "rain" }),
]);
const lines = csv.split("\n");
expect(lines).toHaveLength(3);
expect(lines[0]).toContain("When");
expect(lines[0]).toContain("Headgate");
});
it("quotes fields containing commas, quotes, or newlines", () => {
const csv = waterLogToCSV([
ROW({ notes: 'has, "quotes" and\nnewlines' }),
]);
// The cell value is properly quoted with internal quotes doubled.
// An embedded newline splits the row across two physical lines,
// so we read the full CSV string rather than a single line.
expect(csv).toContain('"has, ""quotes"" and\nnewlines"');
});
it("escapes unicode and emoji", () => {
const csv = waterLogToCSV([
ROW({ user_name: "José 🚜" }),
]);
expect(csv).toContain("José 🚜");
});
it("emits a syntactically valid CSV (parses with a basic split)", () => {
const csv = waterLogToCSV([ROW(), ROW()]);
// 1 header + 2 data rows
expect(csv.split("\n")).toHaveLength(3);
});
});
describe("formatDailyWaterReport", () => {
it("returns null when no rows and sendEvenIfEmpty is false", () => {
expect(formatDailyWaterReport([])).toBeNull();
});
it("emits an empty-day message when sendEvenIfEmpty is true", () => {
const out = formatDailyWaterReport([], { sendEvenIfEmpty: true });
expect(out).toContain("No entries reported today");
});
it("includes the recipient name when provided", () => {
const out = formatDailyWaterReport([ROW()], { recipientName: "David" });
// We don't enforce the salutation format, but the row must be present.
expect(out).toContain("Tyler");
expect(out).toContain("Upper Ditch");
});
it("sums the total measurement", () => {
const out = formatDailyWaterReport([
ROW({ measurement: 2 }),
ROW({ measurement: 3.5 }),
]);
expect(out).toContain("5.50");
});
it("includes notes when present", () => {
const out = formatDailyWaterReport([ROW({ notes: "Ditch running heavy" })]);
expect(out).toContain("Ditch running heavy");
});
});
describe("display age helpers", () => {
it("returns 'never' for null", () => {
expect(getDisplayAgeLabel(null)).toBe("never");
expect(getDisplayAgeColor(null)).toBe("red");
});
it("returns 'just now' under 1 minute", () => {
expect(getDisplayAgeLabel(0.5)).toBe("just now");
expect(getDisplayAgeColor(0.5)).toBe("green");
});
it("buckets by minutes / hours / days", () => {
expect(getDisplayAgeLabel(15)).toBe("15m ago");
expect(getDisplayAgeLabel(90)).toBe("1h ago");
expect(getDisplayAgeLabel(120)).toBe("2h ago");
expect(getDisplayAgeLabel(60 * 26)).toBe("1d ago");
});
it("colors by recency", () => {
expect(getDisplayAgeColor(15)).toBe("green"); // < 30m
expect(getDisplayAgeColor(60)).toBe("yellow"); // < 2h
expect(getDisplayAgeColor(180)).toBe("red"); // > 2h
});
});