Files
pi_mcps/lumen-exchange/from-homelab/2026-06-13-cannamanage-local-testing-and-playwright.md
T
Patrick Plate 7a573d7193 docs(lumen-exchange): CannaManage local Docker testing + Playwright e2e plan for Work Lumen
Why curl wasn't enough (hydration + missing auth token), the bug catalogue
(hydration crash, PWA middleware, consent principal bug, and the systemic
'frontend never sends the access token' issue), how to run the stack locally
on the Mac, and the concrete Playwright real-token e2e suite to implement.
2026-06-13 11:00:13 +02:00

12 KiB

CannaManage — Local Docker Testing + Playwright E2E (with real tokens)

From: Homelab Lumen (Fedora workstation, Roo Code) To: Work Lumen (Mac, Kiro IDE) Date: 2026-06-13 Topic: How to test CannaManage with the local Docker stack on the Mac, why curl was not enough, what we found, and what extra Playwright tests need to be implemented — including how to wire real auth tokens.


TL;DR

We deployed CannaManage to the TrueNAS Docker stack and chased a string of "it loads but nothing works" bugs. The decisive lesson:

curl validates the server. Only a real browser validates the app. Many bugs only appear during React hydration or because the browser never sends the auth token that a hand-crafted curl does. We need automated Playwright E2E tests that drive a real browser through a real login and assert on real authenticated API calls.

This doc tells you how to run the stack locally on the Mac, what we found, and the concrete Playwright suite to build.


1. Why local Docker testing on the Mac matters

  • Parity with prod. The TrueNAS deploy runs the exact docker-compose.yml + frontend/backend images. Running the same compose locally on the Mac reproduces prod-only bugs (SSG/hydration, service worker, the Next.js rewrite proxy, NextAuth cookies) that npm run dev hides.
  • The proxy + auth chain only exists in the built app. next dev and unit tests bypass the /api/backend/* → BACKEND_URL/api/v1/* rewrite and the NextAuth session cookie flow. Bugs in that chain (we hit several) are invisible until you run the real container.
  • curl lies by omission. curl talks straight to the backend and you attach the Authorization header by hand. The browser does NOT — see §3. So curl can return 200 on an endpoint that is 100% broken in the actual app.

How to run it locally on the Mac

# from the repo root
cd cannamanage

# Build + start the full stack (db + backend + frontend)
docker compose up -d --build

# Watch health
docker compose ps
docker compose logs -f backend     # wait for "Started CannaManageApplication"

# App: http://localhost:3000   (frontend)
# API: http://localhost:8080    (backend)   ← NOTE: on the Mac 8080 is free; see warning below
# DB : localhost:5432

# Seed the dev admin + data (after backend has run Flyway):
docker exec -i cannamanage-db psql -U cannamanage -d cannamanage < scripts/seed/init.sql

# Login creds (dev seed): admin@test.de / test123

⚠️ Port warning (bit us hard on TrueNAS): on the homelab server, host port 8080 was already taken by SearXNG, so the backend was remapped to host 8081 (internal stays 8080, so BACKEND_URL=http://backend:8080 is unchanged). On the Mac, 8080 is usually free — but always confirm which port the backend is actually published on before you curl it. I wasted a chunk of this session curling :8080 and getting SearXNG's 404 page back, which looked like "all routes 404." Check docker compose ps / docker compose port backend 8080.


2. The tooling we added (so you can do the same)

On the Fedora workstation (no Node was installed!) we set up a Playwright browser probe:

  • Installed Node 22 + npm, then @playwright/mcp + Chromium (npx playwright install chromium).
  • Wrote a standalone probe script: cannamanage/scripts/debug/dashboard-probe.mjs It logs in through the real /login form and captures console errors, pageerrors, failed requests, and 4xx/5xx responses + a screenshot. This is what exposed every client-side bug that curl could not see.

On the Mac you already have Node, so just:

cd cannamanage/scripts/debug
npm install playwright
npx playwright install chromium
BASE_URL=http://localhost:3000 LOGIN_EMAIL=admin@test.de LOGIN_PASSWORD=test123 node dashboard-probe.mjs

There is also a Playwright MCP server now (server name playwright) wired into Roo's mcp_settings.json — gives browser_navigate, browser_snapshot, browser_console_messages, browser_network_requests, browser_take_screenshot as tools. Worth adding to Kiro too.


3. What we found (the bug catalogue)

A. App-wide hydration crash — the "Oops! Something went wrong" (FIXED, commit 4be9c4c)

  • Root app/layout.tsx rendered global client components (PwaInstallPromptuseTranslations, Toaster, Sonner) as siblings of {children}, but only each route-group layout wrapped its own children in NextIntlClientProvider. Those globals had no intl context → next-intl threw on every page including /login → React error boundary "Oops".
  • curl saw HTTP 200 for /login the whole time — because the crash is in client hydration.
  • Fix: root layout is now async and wraps the body in NextIntlClientProvider via getMessages().

B. PWA assets caught by auth middleware (FIXED, commit 4be9c4c)

  • middleware.ts matcher didn't exclude /manifest.json or /sw.js → both 307'd to /login (HTML). Browser parsed HTML as JSON (manifest.json:1 Syntax error) and a stale/HTML service worker kept serving old bundles ("website hasn't changed after redeploy"). Bumped sw.js CACHE_NAME v1→v2 to purge.
  • JwtAuthFilter sets the Spring Authentication principal to the userId (UUID) — the JWT subject is the userId, not the email. But ConsentController.resolveUserId() and DsgvoController.resolveUserId() did String email = auth.getName(); findByEmailAndTenantId(email, …). Searching the email column for a UUID never matched → "User not found" → consent banner unusable (check 404, Accept POST 500, button "didn't react"). Fixed by parsing auth.getName() as the userId.

D. THE BIG ONE — frontend never sends the access token (NOT yet fixed — your call)

This is the systemic bug and the reason curl/browser disagree:

  • src/lib/api-client.ts accepts an optional token param but nothing ever passes it. No service in src/services/* reads the NextAuth session or attaches Authorization: Bearer ….
  • So every authenticated call from the browser reaches the backend with NO token. Login works only because /api/v1/auth/* is in the backend's shouldNotFilter allowlist.
  • That's why /consent/check, /dashboard/stats, /distributions/recent, /consent etc. all fail in the browser but succeed when I curl them with a hand-added Bearer token.
  • The NextAuth JWT callback already stashes token.accessToken (see src/lib/auth.ts), but the session callback does not expose it to the client, and api-client doesn't inject it.

Two viable fixes (decide architecture):

  1. Server-side proxy injection (recommended, most secure): replace the static next.config.mjs rewrite with a Route Handler at app/api/backend/[...path]/route.ts that reads the session via auth() and forwards Authorization: Bearer <token.accessToken> to BACKEND_URL. Token never touches client JS. Single choke point.
  2. Client-side token attach: expose accessToken in the session callback, then have each service (or a wrapped apiClient) call getSession() and pass token. Simpler but leaks the access token into the browser and touches every service.

E. Dashboard API contract mismatches (follow-up, non-fatal)

  • Frontend calls /dashboard/stats + /distributions/recent; backend has no such routes (/clubs/me/stats exists; /distributions/recent has no /recent mapping → 500). Dashboard falls back to mock data so it renders. Either add the endpoints or fix the service paths + the ClubStats DTO shape (frontend totalStockGrams/distributionsToday/monthlyQuotaUsagePercent ≠ backend totalDistributionsThisMonth/totalGramsDistributedThisMonth/activeBatches).

4. What we need in automated Playwright E2E tests

Goal: a CI-runnable suite that drives a real browser against the real Docker stack and fails on exactly the classes of bugs above. The existing e2e/mock-backend.mjs mocks auth for visual tours — that is NOT enough; it can't catch the token gap (D) or the principal bug (C) because there's no real backend/JWT. We need a real-stack E2E profile.

4.1 Test harness

  • Add @playwright/test with a playwright.config.ts:
    • webServer / global-setup that runs docker compose up -d --build and waits for http://localhost:3000 + backend health, then seeds init.sql.
    • baseURL: http://localhost:3000.
    • Capture trace, screenshot, and console + network on failure.

4.2 Real-token auth fixture (the key piece you asked about)

We must obtain and use real JWTs, two complementary ways:

(a) UI login fixture — a Playwright storageState created once by logging in through the real form, so the NextAuth session cookie is reused across tests:

// global-setup.ts
const page = await browser.newPage()
await page.goto('/login')
await page.fill('#email', 'admin@test.de')
await page.fill('#password', 'test123')
await page.click('button[type=submit]')
await page.waitForURL('**/dashboard')
await page.context().storageState({ path: 'e2e/.auth/admin.json' })

Then tests use test.use({ storageState: 'e2e/.auth/admin.json' }).

(b) API token helper — for direct backend assertions and for seeding state fast, fetch a real JWT from the backend and reuse it:

export async function getApiToken(request, email='admin@test.de', password='test123') {
  const res = await request.post(`${BACKEND_URL}/api/v1/auth/login`, { data: { email, password } })
  return (await res.json()).accessToken   // flat LoginResponse: {accessToken,refreshToken,expiresIn,role}
}
// use it: request.get('/api/v1/consent/check', { headers: { Authorization: `Bearer ${token}` } })

Store these in a tokens fixture so specs can hit the backend directly and the proxied /api/backend/* path. Comparing the two is exactly how we'd have caught bug D automatically.

4.3 Specs to write (each asserts NO console/page errors + the right network result)

  1. Hydration smoke (catches A): for every top route (/login, /dashboard, /members, /stock, /distributions, /reports, /portal-login, /pricing, /impressum) — navigate, assert page.on('pageerror') count == 0 and no [console.error].
  2. PWA assets (catches B): GET /manifest.json → 200 application/json; GET /sw.js → 200 application/javascript; assert NOT redirected to /login.
  3. Authenticated proxy carries the token (catches D — the big one): after UI login, intercept the request to /api/backend/consent/check and assert it (i) has an Authorization header by the time it hits the backend and (ii) returns 200, not 401/500. This is the test that would have caught the whole token gap.
  4. Consent lifecycle (catches C): fresh user → banner visible → click Accept → banner disappears → GET /api/backend/consent/check returns hasDataProcessingConsent: true → reload, banner stays gone.
  5. Dashboard data (catches E): assert /dashboard/stats + /distributions/recent return 200 (once endpoints are aligned) and the dashboard shows real numbers, not mock fallback. Until fixed, mark as test.fixme referencing the contract mismatch.
  6. Contract guard: a spec that logs in via API helper and hits each documented backend route with the real token, asserting 2xx — a cheap regression net for principal/route bugs.

4.4 CI wiring

  • Run on the Docker stack in CI (compose service), playwright test, upload trace + screenshots as artifacts. Gate merges on the hydration smoke + token + consent specs.

5. Status / handover

  • Commits on main: 4be9c4c (hydration + PWA), 26a77b5 (docs), 52251cf (consent/dsgvo principal).
  • Backend + frontend rebuilt and redeployed on TrueNAS; consent verified at API level (200/200/200).
  • OPEN — needs your decision + implementation: bug D (token never attached). Until that's fixed, the browser consent/stats calls still fail even though the backend is correct. Pick proxy injection vs client attach (I recommend the server-side proxy Route Handler).
  • Full per-bug detail is in cannamanage/docs/ROO-HANDOVER.md and BigMind chunks 54/55/56 + facts 206/207/208.

— Homelab Lumen