/** * Water Log — end-to-end tests. * * These cover the user-visible surface of the Water Log feature across * both PIN-authenticated portals and the site-admin view. The intent is * to catch the most common production regressions: page no longer * renders, PIN screen broken, admin gate missing, CSV export leaking * unauthenticated data, etc. * * Heavy multi-actor flows (admin adds headgate → irrigator submits entry * → admin exports CSV) require a working DB + a fully-provisioned * Tuxedo brand, so they're behind a `WATER_LOG_E2E_DB=1` env flag. The * default suite stays runnable against any deployment. * * Run locally: * PLAYWRIGHT_URL=http://localhost:3000 npx playwright test water-log * # Or against prod (default in playwright.config): * npx playwright test water-log */ import { test, expect, request } from "@playwright/test"; const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000"; test.describe("Water Log — public surfaces", () => { test("/water renders the PIN entry form", async ({ page }) => { const res = await page.goto(`${BASE}/water`); expect(res?.status()).toBeLessThan(500); // PIN input is a password input with inputMode=numeric const pin = page.locator('input[type="password"][inputmode="numeric"]').first(); await expect(pin).toBeVisible({ timeout: 10_000 }); }); test("/water PIN input strips non-digits and caps at 4 chars", async ({ page }) => { await page.goto(`${BASE}/water`); const pin = page.locator('input[type="password"][inputmode="numeric"]').first(); await expect(pin).toBeVisible({ timeout: 10_000 }); await pin.fill("12ab34cd56ef"); const value = await pin.inputValue(); expect(value).toMatch(/^\d{0,4}$/); expect(value.length).toBeLessThanOrEqual(4); }); test("/water/admin renders the admin PIN entry form", async ({ page }) => { const res = await page.goto(`${BASE}/water/admin/login`); expect(res?.status()).toBeLessThan(500); const pin = page.locator('input[type="password"][inputmode="numeric"]').first(); await expect(pin).toBeVisible({ timeout: 10_000 }); }); test("/water/admin wrong PIN shows an error, does not navigate", async ({ page }) => { await page.goto(`${BASE}/water/admin/login`); const pin = page.locator('input[type="password"][inputmode="numeric"]').first(); await expect(pin).toBeVisible({ timeout: 10_000 }); // Use a clearly-invalid 4-digit PIN. We don't know the configured // PIN, so 0000 is the canonical "wrong" value. await pin.fill("0000"); const submit = page.getByRole("button", { name: /sign in|submit|enter/i }).first(); if (await submit.isVisible().catch(() => false)) { await submit.click(); } else { await pin.press("Enter"); } // After a wrong PIN, the URL should still be the login page. await page.waitForTimeout(500); expect(page.url()).toMatch(/\/water\/admin\/login/); }); }); test.describe("Water Log — no platform login required (regression)", () => { // The water log module is PIN-based and self-contained. A prior // version of the server actions called getSession() (Neon Auth) on // every PIN-screen interaction, which caused `/water` to hang or // fail for users with no platform login. These tests assert that // a fresh, cookie-free browser context can complete the public // surfaces without ever touching /login. test("/water full flow does not redirect to /login with no cookies", async ({ browser }) => { // Fresh context: no storageState, no cookies, no dev_session. const ctx = await browser.newContext(); const page = await ctx.newPage(); const hits: string[] = []; page.on("request", (r) => { const u = r.url(); if (u.startsWith(BASE)) hits.push(u); }); const res = await page.goto(`${BASE}/water`, { waitUntil: "domcontentloaded" }); expect(res?.status()).toBe(200); // Walk through language → role. The exact button labels depend on // the i18n, but the Irrigator path is the green button labeled // "Irrigator" and the language step is the first English/Español // choice. await page.getByRole("button", { name: /^english$/i }).click({ timeout: 5_000 }).catch(async () => { // Some renderings show "English" as a label rather than exact // text — fall back to the first button in the language picker. await page.locator("button").filter({ hasText: /english/i }).first().click(); }); await page.getByRole("button", { name: /^irrigator$/i }).click({ timeout: 5_000 }); // The PIN input is now visible. const pin = page.locator('input[type="password"][inputmode="numeric"]').first(); await expect(pin).toBeVisible({ timeout: 10_000 }); // We must never have hit /login. const loginHits = hits.filter((u) => u.includes("/login")); expect(loginHits).toEqual([]); await ctx.close(); }); test("GET /api/water-admin-auth works with no cookies (returns 401, not 302/500)", async () => { const ctx = await request.newContext({ baseURL: BASE, storageState: { cookies: [], origins: [] }, }); // Any 4-digit PIN, no cookies — must NOT 302 to /login and must // NOT 500 (server error). 401/403 are the correct gates. const res = await ctx.post("/api/water-admin-auth", { data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "0000" }, maxRedirects: 0, }); expect([200, 401, 403]).toContain(res.status()); await ctx.dispose(); }); test("/water responds 200 to a no-cookie GET (no Neon Auth hang)", async () => { const ctx = await request.newContext({ baseURL: BASE, storageState: { cookies: [], origins: [] }, }); // Bounded timeout — if the server-action pre-call is hanging on // Neon Auth, this will throw. 8s is generous for a static page // render and tight enough to fail fast on a hang. const res = await ctx.get("/water", { timeout: 8_000 }); expect(res.status()).toBe(200); await ctx.dispose(); }); }); test.describe("Water Log — site-admin gate", () => { test("/admin/water-log redirects unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/admin/water-log`, { waitUntil: "domcontentloaded" }); // Should not stay on /admin/water-log if not signed in. expect(page.url()).not.toMatch(/\/admin\/water-log$/); }); test("/admin/water-log/settings redirects unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/admin/water-log/settings`, { waitUntil: "domcontentloaded" }); expect(page.url()).not.toMatch(/\/admin\/water-log\/settings$/); }); test("/admin/water-log/headgates redirects unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/admin/water-log/headgates`, { waitUntil: "domcontentloaded" }); expect(page.url()).not.toMatch(/\/admin\/water-log\/headgates$/); }); test("/admin/water-log/users redirects unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/admin/water-log/users`, { waitUntil: "domcontentloaded" }); expect(page.url()).not.toMatch(/\/admin\/water-log\/users$/); }); test("/admin/water-log/entries redirects unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/admin/water-log/entries`, { waitUntil: "domcontentloaded" }); expect(page.url()).not.toMatch(/\/admin\/water-log\/entries$/); }); }); test.describe("Water Log — API auth", () => { test("/api/water-logs/export returns 401/403 for unauthenticated callers", async () => { const ctx = await request.newContext({ baseURL: BASE }); const res = await ctx.get("/api/water-logs/export"); // Either 401 (no admin session) or 403 (signed in but not permitted) // — both are correct, the point is the endpoint doesn't leak data. expect([401, 403]).toContain(res.status()); await ctx.dispose(); }); test("/api/water-admin-auth rejects malformed PINs (length != 4)", async () => { const ctx = await request.newContext({ baseURL: BASE }); const res = await ctx.post("/api/water-admin-auth", { data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "12" }, }); // 400 (bad request) or 401/403 (no admin user) — both are valid // gates. Critically, it must NOT be 200. expect(res.status()).not.toBe(200); await ctx.dispose(); }); test("/api/water-admin-auth rejects non-numeric PINs", async () => { const ctx = await request.newContext({ baseURL: BASE }); const res = await ctx.post("/api/water-admin-auth", { data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "abcd" }, }); expect(res.status()).not.toBe(200); await ctx.dispose(); }); }); test.describe("Water Log — full workflow (DB required)", () => { // These tests need a live database with the Tuxedo brand provisioned // and the migrations from 0090 applied. Skipped by default so the // suite still runs against any deployment. Set WATER_LOG_E2E_DB=1 // (and ensure BASE points at a dev DB) to opt in. test.skip(!process.env.WATER_LOG_E2E_DB, "requires WATER_LOG_E2E_DB=1 + a live DB"); test("site admin can add a headgate and an irrigator, then export CSV", async ({ page }) => { // 1. Sign in as a platform_admin or Tuxedo brand_admin await page.goto(`${BASE}/login`); // The login flow is environment-specific (Google OAuth, email link, // or local dev session). Try the dev session form first. const emailInput = page.getByLabel(/email/i).first(); if (await emailInput.isVisible().catch(() => false)) { await emailInput.fill(process.env.WATER_LOG_TEST_ADMIN_EMAIL ?? "admin@tuxedo.example"); const passwordInput = page.getByLabel(/password/i).first(); if (await passwordInput.isVisible().catch(() => false)) { await passwordInput.fill(process.env.WATER_LOG_TEST_ADMIN_PASSWORD ?? "test-password"); } await page.getByRole("button", { name: /sign in/i }).first().click(); } // 2. Navigate to the Water Log admin await page.goto(`${BASE}/admin/water-log`); await expect(page.getByRole("heading", { name: /water log/i }).first()).toBeVisible({ timeout: 15_000, }); // 3. Add a headgate const hgName = `E2E Headgate ${Date.now()}`; const addHg = page.getByRole("button", { name: /add headgate/i }).first(); if (await addHg.isVisible().catch(() => false)) { await addHg.click(); const nameInput = page.getByPlaceholder(/north field gate|gate/i).first(); await nameInput.fill(hgName); await page.getByRole("button", { name: /^save|create|add$/i }).first().click(); } await expect(page.getByText(hgName).first()).toBeVisible({ timeout: 10_000 }); // 4. Add an irrigator const irrName = `E2E Irrigator ${Date.now()}`; const addUser = page.getByRole("button", { name: /add (water )?user|add irrigator/i }).first(); if (await addUser.isVisible().catch(() => false)) { await addUser.click(); const nameInput = page.getByPlaceholder(/full name/i).first(); await nameInput.fill(irrName); await page.getByRole("button", { name: /^save|create|add$/i }).first().click(); } await expect(page.getByText(irrName).first()).toBeVisible({ timeout: 10_000 }); // 5. Hit the CSV export endpoint and confirm it returns CSV const ctx = await request.newContext({ baseURL: BASE, storageState: await page.context().storageState(), }); const res = await ctx.get("/api/water-logs/export?format=csv"); expect(res.status()).toBe(200); const contentType = res.headers()["content-type"] ?? ""; expect(contentType).toContain("text/csv"); const body = await res.text(); expect(body.split("\n").length).toBeGreaterThanOrEqual(1); await ctx.dispose(); }); });