Parcourir la source

Add E2E test for 4 flows.

Frédéric G. MARAND il y a 6 heures
Parent
commit
bdbe709824
12 fichiers modifiés avec 442 ajouts et 12 suppressions
  1. 4 0
      .claudeignore
  2. 2 0
      .gitignore
  3. 4 1
      .idea/jamtrack.iml
  4. 58 1
      README.md
  5. 281 0
      e2e/flows.mjs
  6. 59 0
      e2e/package-lock.json
  7. 9 0
      e2e/package.json
  8. 2 0
      example.env
  9. 4 2
      go.mod
  10. 6 4
      go.sum
  11. 11 2
      justfile
  12. 2 2
      main.go

+ 4 - 0
.claudeignore

@@ -0,0 +1,4 @@
+e2e/node_modules/
+.env
+pb_data/
+bin/

+ 2 - 0
.gitignore

@@ -1,3 +1,5 @@
 bin/
 pb_data/
 pb_migrations_backup/
+e2e/node_modules/
+.env

+ 4 - 1
.idea/jamtrack.iml

@@ -2,7 +2,10 @@
 <module type="WEB_MODULE" version="4">
   <component name="Go" enabled="true" />
   <component name="NewModuleRootManager">
-    <content url="file://$MODULE_DIR$" />
+    <content url="file://$MODULE_DIR$">
+      <excludeFolder url="file://$MODULE_DIR$/bin" />
+      <excludeFolder url="file://$MODULE_DIR$/pb_data" />
+    </content>
     <orderEntry type="inheritedJdk" />
     <orderEntry type="sourceFolder" forTests="false" />
   </component>

+ 58 - 1
README.md

@@ -2,7 +2,64 @@
 
 ## Description
 
-This is a simple jam tracker that allows you to keep track of your favorite jams and listen to them whenever you want.
+A PocketBase-backed web application for tracking jam sessions. Uses `templ` for
+type-safe HTML templates and `modernc.org/sqlite` (via PocketBase) for persistence.
+
+## Development
+
+```
+just dev      # live-reload dev server with templ watch proxy (port 7331 → 8090)
+just build    # generate templates + compile binary to bin/jamtrack
+just serve    # run the prebuilt binary
+just test     # go test -race ./...
+just gen      # regenerate templ templates only
+```
+
+The PocketBase admin UI is available at `http://localhost:8090/_/` while the server
+is running.
+
+## E2E tests
+
+The Playwright E2E suite exercises the four main user flows in a real browser against a running dev server.
+
+### One-time setup
+
+Requires Node.js. Run once (or after updating `package.json`):
+
+```
+just e2e-setup
+```
+
+This installs the `playwright` npm package and downloads the Chromium browser.
+`node_modules/` inside `e2e/` is git-ignored.
+
+### Credentials
+
+The suite needs PocketBase superuser credentials to authenticate against the admin
+API. It creates a throwaway app user at the start of each run and deletes it on
+exit, so no app-level credentials need to be configured.
+
+Copy `example.env` to `.env` (git-ignored) and fill in real values:
+
+```sh
+cp example.env .env
+$EDITOR .env
+```
+
+`.env` uses plain `KEY=VALUE` format (no `export`). `just e2e` reads it
+automatically via `go tool envrun`.
+
+The suite creates `APP_USER` in the `users` collection on first run if it does not
+already exist.
+
+### Running
+
+```
+just e2e
+```
+
+The dev server (`just dev` or `just serve`) must be running on port 8090/7331
+before the suite is invoked.
 
 ## License
 

+ 281 - 0
e2e/flows.mjs

@@ -0,0 +1,281 @@
+/**
+ * jamtrack E2E flow verification using Playwright (Node ESM).
+ *
+ * Requires: `npm install playwright` in the working directory,
+ * and `npx playwright install chromium` to download the browser.
+ *
+ * Setup notes discovered during first run:
+ *  - App frontend at http://localhost:7331 (templ dev proxy, port 8090 backend).
+ *    Login uses username (not email): input[name="username"].
+ *    Location select on /jams/new is named "location" (not "location_id").
+ *  - Location delete: the app UI has NO delete button for locations.
+ *    The backend hook (main.go:OnRecordDelete) rejects the API call with HTTP 400.
+ *    PocketBase's firstApiError() recognises router.NewBadRequestError, so the
+ *    custom message surfaces. Test via the PocketBase REST API with a superuser token.
+ *  - App users are created/deleted via the PocketBase REST API using a superuser
+ *    token. No admin UI interaction is needed.
+ *  - All test-created data carries an "[E2E]" prefix in its name/artist fields.
+ *    This makes leftover data from failed runs easy to spot and delete by hand in
+ *    the PocketBase admin UI: filter locations by name ~ "[E2E]", songs by
+ *    artist ~ "[E2E]". cleanupTestData() also runs at test start to purge any
+ *    leftovers before starting fresh.
+ *
+ * Credentials: only ADMIN_EMAIL / ADMIN_PASS are needed in .env.
+ * The suite generates a throwaway app user per run and deletes it on exit.
+ *
+ * Run:
+ *   just e2e   (reads credentials from .env via go tool envrun)
+ */
+
+import { chromium } from 'playwright';
+import { randomBytes } from 'node:crypto';
+
+const PB_API      = 'http://localhost:8090';
+const APP_URL     = 'http://localhost:7331';
+const ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? (() => { throw new Error('ADMIN_EMAIL required'); })();
+const ADMIN_PASS  = process.env.ADMIN_PASS  ?? (() => { throw new Error('ADMIN_PASS required'); })();
+
+// Telltale marker on all test-created records — easy to spot in the admin UI.
+const E2E_PREFIX   = '[E2E]';
+const E2E_LOCATION = `${E2E_PREFIX} Test Location`;
+
+// Throwaway credentials generated fresh each run.
+const APP_USER = `e2e_${Date.now()}`;
+const APP_PASS = randomBytes(16).toString('hex');
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const log   = msg  => console.log(`[${new Date().toISOString()}] ${msg}`);
+
+// Returns a superuser JWT for PocketBase API calls.
+async function pbToken() {
+  const r = await fetch(`${PB_API}/api/collections/_superusers/auth-with-password`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASS }),
+  });
+  const body = await r.json();
+  if (!r.ok) throw new Error(`Admin auth failed (${r.status}): ${body.message} — check ADMIN_EMAIL and ADMIN_PASS in .env`);
+  return body.token;
+}
+
+async function createAppUser(token) {
+  const r = await fetch(`${PB_API}/api/collections/users/records`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Authorization: token },
+    body: JSON.stringify({
+      username:        APP_USER,
+      email:           `${APP_USER}@test.example`,
+      password:        APP_PASS,
+      passwordConfirm: APP_PASS,
+    }),
+  });
+  const body = await r.json();
+  if (!r.ok) throw new Error(`Failed to create app user: ${body.message}`);
+  log(`app user "${APP_USER}" created`);
+  return body.id;
+}
+
+async function deleteAppUser(token, userId) {
+  const r = await fetch(`${PB_API}/api/collections/users/records/${userId}`, {
+    method: 'DELETE',
+    headers: { Authorization: token },
+  });
+  if (!r.ok) {
+    const body = await r.json();
+    throw new Error(`Failed to delete app user: ${body.message}`);
+  }
+  log(`app user "${APP_USER}" deleted`);
+}
+
+// Creates the [E2E] test location. cleanupTestData() removes it including any
+// jams attached to it, so this is idempotent across runs as long as cleanup runs.
+async function createE2ELocation(token) {
+  const r = await fetch(`${PB_API}/api/collections/locations/records`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Authorization: token },
+    body: JSON.stringify({ name: E2E_LOCATION }),
+  });
+  const body = await r.json();
+  if (!r.ok) throw new Error(`Failed to create test location: ${body.message}`);
+  log(`test location "${E2E_LOCATION}" created`);
+  return body.id;
+}
+
+// Deletes all [E2E]-marked test data: jams at the E2E location (cascade removes
+// their setlist entries), remaining [E2E] songs and any dangling setlist entries,
+// and finally the E2E location itself. Safe to call at start and end of each run.
+async function cleanupTestData(token) {
+  // Find all [E2E] locations (should be at most one, but handle stragglers).
+  const locFilter = encodeURIComponent(`name ~ '${E2E_PREFIX}'`);
+  const locsR = await fetch(`${PB_API}/api/collections/locations/records?filter=${locFilter}&perPage=200`, {
+    headers: { Authorization: token },
+  });
+  const locs = await locsR.json();
+
+  for (const loc of locs.items ?? []) {
+    // Delete all jams at this location; cascade removes their setlist entries.
+    const jamFilter = encodeURIComponent(`location='${loc.id}'`);
+    const jamsR = await fetch(`${PB_API}/api/collections/jams/records?filter=${jamFilter}&perPage=200`, {
+      headers: { Authorization: token },
+    });
+    const jams = await jamsR.json();
+    await Promise.all((jams.items ?? []).map(j =>
+      fetch(`${PB_API}/api/collections/jams/records/${j.id}`, {
+        method: 'DELETE', headers: { Authorization: token },
+      })
+    ));
+    if (jams.items?.length) log(`${jams.items.length} test jam(s) at "${loc.name}" deleted`);
+
+    // Now the location has no jams, so the OnRecordDelete hook allows deletion.
+    await fetch(`${PB_API}/api/collections/locations/records/${loc.id}`, {
+      method: 'DELETE', headers: { Authorization: token },
+    });
+    log(`test location "${loc.name}" deleted`);
+  }
+
+  // Delete [E2E] songs and any remaining setlist entries referencing them
+  // (e.g. songs added to existing jams in Flow 2).
+  const songFilter = encodeURIComponent(`artist ~ '${E2E_PREFIX}'`);
+  const songsR = await fetch(`${PB_API}/api/collections/songs/records?filter=${songFilter}&perPage=200`, {
+    headers: { Authorization: token },
+  });
+  const songsList = await songsR.json();
+
+  for (const song of songsList.items ?? []) {
+    const slFilter = encodeURIComponent(`song='${song.id}'`);
+    const slR = await fetch(`${PB_API}/api/collections/setlist/records?filter=${slFilter}&perPage=200`, {
+      headers: { Authorization: token },
+    });
+    const sl = await slR.json();
+    await Promise.all((sl.items ?? []).map(s =>
+      fetch(`${PB_API}/api/collections/setlist/records/${s.id}`, {
+        method: 'DELETE', headers: { Authorization: token },
+      })
+    ));
+    await fetch(`${PB_API}/api/collections/songs/records/${song.id}`, {
+      method: 'DELETE', headers: { Authorization: token },
+    });
+  }
+  if (songsList.items?.length) log(`${songsList.items.length} [E2E] song(s) and dangling setlist entries deleted`);
+}
+
+(async () => {
+  const token = await pbToken();
+
+  // Purge leftovers from any previously failed run before starting fresh.
+  await cleanupTestData(token);
+  await createE2ELocation(token);
+
+  const browser = await chromium.launch({ headless: true });
+  const userId  = await createAppUser(token);
+
+  try {
+    // ── App session ────────────────────────────────────────────────────────
+    const app = await browser.newContext().then(c => c.newPage());
+    await app.goto(`${APP_URL}/login`);
+    await app.fill('input[name="username"]', APP_USER);
+    await app.fill('input[name="password"]', APP_PASS);
+    await app.click('button[type="submit"]');
+    await app.waitForURL(`${APP_URL}/`, { timeout: 8000 });
+    log('app login OK');
+    const dashBefore = await app.textContent('body');
+
+    // ── Flow 1: Logout → /login ────────────────────────────────────────────
+    log('Flow 1: logout');
+    await app.locator('a[href="/logout"], a:has-text("Logout")').first().click();
+    await app.waitForURL(/\/login/, { timeout: 5000 });
+    const flow1 = app.url().includes('/login') ? 'PASS' : `FAIL — landed at ${app.url()}`;
+    console.log(`FLOW1: ${flow1}`);
+
+    // Re-login for remaining flows.
+    await app.fill('input[name="username"]', APP_USER);
+    await app.fill('input[name="password"]', APP_PASS);
+    await app.click('button[type="submit"]');
+    await app.waitForURL(`${APP_URL}/`, { timeout: 8000 });
+
+    // ── Flow 2: Focus-after-add ────────────────────────────────────────────
+    log('Flow 2: focus-after-add');
+    await app.goto(`${APP_URL}/jams`);
+    await sleep(500);
+    // Exclude /jams/new and only look at real (non-E2E) jams.
+    const jamLinks = app.locator('a[href^="/jams/"]:not([href="/jams/new"])');
+    const jamCount = await jamLinks.count();
+    let flow2;
+    if (jamCount > 0) {
+      await jamLinks.first().click();
+      await app.waitForURL(/\/jams\/\w+/, { timeout: 5000 });
+      await app.fill('input[name="artist"]', `${E2E_PREFIX} Artist`);
+      await app.fill('input[name="title"]',  `${E2E_PREFIX} Song`);
+      await app.locator('button[type="submit"]').last().click();
+      await sleep(700); // wait for HTMX swap + setTimeout(0) focus
+      const focused = await app.evaluate(() => document.activeElement?.getAttribute('name') ?? document.activeElement?.tagName);
+      flow2 = focused === 'artist' ? 'PASS' : `FAIL — focused="${focused}"`;
+    } else {
+      flow2 = 'SKIP — no existing jams';
+    }
+    console.log(`FLOW2: ${flow2}`);
+
+    // ── Flow 3: Location delete blocked (PocketBase API) ──────────────────
+    // The app UI exposes no delete button for locations. The OnRecordDelete hook
+    // in main.go returns router.NewBadRequestError, surfaced by firstApiError().
+    log('Flow 3: location delete blocked');
+    let flow3;
+    try {
+      const locsR = await fetch(`${PB_API}/api/collections/locations/records?perPage=1`, {
+        headers: { Authorization: token },
+      });
+      const locs  = await locsR.json();
+      // Use the first non-E2E location to ensure it has real jams attached.
+      const loc   = (locs.items ?? []).find(l => !l.name.startsWith(E2E_PREFIX));
+      if (!loc) throw new Error('no non-E2E location found to test deletion');
+      const delR  = await fetch(`${PB_API}/api/collections/locations/records/${loc.id}`, {
+        method: 'DELETE',
+        headers: { Authorization: token },
+      });
+      const status = delR.status;
+      const body   = await delR.text();
+      const blocked = status === 400 && /cannot delete a location that still has jams/i.test(body);
+      flow3 = blocked
+        ? `PASS — DELETE returned ${status}`
+        : `FAIL — expected 400 block, got ${status}: ${body.slice(0, 100)}`;
+    } catch (err) {
+      flow3 = `ERROR — ${err.message}`;
+    }
+    console.log(`FLOW3: ${flow3}`);
+
+    // ── Flow 4: Dashboard ranking update ──────────────────────────────────
+    log('Flow 4: dashboard ranking');
+    await app.goto(`${APP_URL}/jams/new`);
+    await sleep(400);
+    await app.fill('input[name="date"]', new Date().toISOString().slice(0, 10));
+    const locSel = app.locator('select[name="location"]'); // NOTE: field is "location", not "location_id"
+    let flow4;
+    try {
+      // Select the [E2E] location by label — no guessing by index.
+      await locSel.selectOption({ label: E2E_LOCATION });
+      await app.click('button[type="submit"]');
+      await app.waitForURL(/\/jams\/\w+/, { timeout: 8000 });
+      await app.fill('input[name="artist"]', `${E2E_PREFIX} Rank Artist`);
+      await app.fill('input[name="title"]',  `${E2E_PREFIX} Rank Song`);
+      await app.locator('button[type="submit"]').last().click();
+      await sleep(700);
+      await app.goto(`${APP_URL}/`);
+      await sleep(500);
+      const dashAfter = await app.textContent('body');
+      const changed   = dashBefore.trim() !== dashAfter.trim();
+      const visible   = dashAfter.includes(`${E2E_PREFIX} Rank`);
+      flow4 = (changed || visible)
+        ? `PASS — changed=${changed}, new song visible=${visible}`
+        : 'UNCERTAIN — dashboard unchanged';
+    } catch (err) {
+      flow4 = `ERROR — ${err.message}`;
+    }
+    console.log(`FLOW4: ${flow4}`);
+
+  } finally {
+    await browser.close();
+    await cleanupTestData(token);
+    await deleteAppUser(token, userId);
+    log('done');
+  }
+})();

+ 59 - 0
e2e/package-lock.json

@@ -0,0 +1,59 @@
+{
+  "name": "jamtrack-e2e",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "jamtrack-e2e",
+      "version": "1.0.0",
+      "dependencies": {
+        "playwright": "^1.61.0"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.61.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
+      "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.61.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.61.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
+      "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    }
+  }
+}

+ 9 - 0
e2e/package.json

@@ -0,0 +1,9 @@
+{
+  "name": "jamtrack-e2e",
+  "version": "1.0.0",
+  "description": "Playwright E2E flows for jamtrack",
+  "type": "module",
+  "dependencies": {
+    "playwright": "^1.61.0"
+  }
+}

+ 2 - 0
example.env

@@ -0,0 +1,2 @@
+ADMIN_EMAIL=support@example.com   # PocketBase superuser email
+ADMIN_PASS=changeme               # PocketBase superuser password

+ 4 - 2
go.mod

@@ -9,7 +9,7 @@ require (
 )
 
 require (
-	github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
+	github.com/BurntSushi/toml v1.6.0 // indirect
 	github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
@@ -19,6 +19,7 @@ require (
 	github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/fatih/color v1.19.0 // indirect
+	github.com/fgm/envrun v0.0.0-20260406202822-32fd1cb0852e // indirect
 	github.com/fsnotify/fsnotify v1.7.0 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.13 // indirect
 	github.com/ganigeorgiev/fexpr v0.5.0 // indirect
@@ -35,7 +36,7 @@ require (
 	github.com/spf13/cobra v1.10.2 // indirect
 	github.com/spf13/pflag v1.0.10 // indirect
 	golang.org/x/crypto v0.52.0 // indirect
-	golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
+	golang.org/x/exp/typeparams v0.0.0-20260312153236-7ab1446f8b90 // indirect
 	golang.org/x/image v0.41.0 // indirect
 	golang.org/x/mod v0.35.0 // indirect
 	golang.org/x/net v0.55.0 // indirect
@@ -53,5 +54,6 @@ require (
 
 tool (
 	github.com/a-h/templ/cmd/templ
+	github.com/fgm/envrun
 	honnef.co/go/tools/cmd/staticcheck
 )

+ 6 - 4
go.sum

@@ -1,5 +1,5 @@
-github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
-github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
 github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
@@ -25,6 +25,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
 github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/fgm/envrun v0.0.0-20260406202822-32fd1cb0852e h1:X94ScnEl2MWJmr3/jB4lmS+MjskL1Gf7huuB7hdmsWo=
+github.com/fgm/envrun v0.0.0-20260406202822-32fd1cb0852e/go.mod h1:72o3L+8vVm1jQ80ma+BA4Y+QfYn+cYkk/syrSEZ8GwU=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -88,8 +90,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
 golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
-golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
-golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+golang.org/x/exp/typeparams v0.0.0-20260312153236-7ab1446f8b90 h1:cfW8UCYSVdPblxA7qQe3o5Iad55Vsx4BFmuGS9RNOmc=
+golang.org/x/exp/typeparams v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
 golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
 golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=

+ 11 - 2
justfile

@@ -2,13 +2,22 @@ gen:
     go tool templ generate
 
 dev:
-    go tool templ generate --watch --proxy="http://127.0.0.1:8090" --cmd="env -u TEMPL_DEV_MODE go run . serve"
+    go tool templ generate --watch --proxy="http://127.0.0.1:8090" --cmd="env -u TEMPL_DEV_MODE go run . serve --dir pb_data"
 
 build:
     go tool templ generate && go build -o bin/jamtrack .
 
 serve:
-    ./bin/jamtrack serve
+    ./bin/jamtrack serve --dir pb_data
 
 test:
     go test -race ./...
+
+# Install E2E dependencies and download Chromium (run once, or after updating package.json).
+e2e-setup:
+    cd e2e && npm install && npx playwright install chromium
+
+# Run Playwright E2E flows against the running dev server.
+# Credentials are read from .env (see README and example.env).
+e2e:
+    go tool envrun node e2e/flows.mjs

+ 2 - 2
main.go

@@ -2,7 +2,6 @@
 package main
 
 import (
-	"errors"
 	"log"
 	"os"
 	"strings"
@@ -10,6 +9,7 @@ import (
 	"github.com/pocketbase/pocketbase"
 	"github.com/pocketbase/pocketbase/core"
 	"github.com/pocketbase/pocketbase/plugins/migratecmd"
+	"github.com/pocketbase/pocketbase/tools/router"
 
 	"code.osinet.fr/fgm/jamtrack/internal/web"
 	_ "code.osinet.fr/fgm/jamtrack/migrations"
@@ -34,7 +34,7 @@ func main() {
 			return err
 		}
 		if len(jams) > 0 {
-			return errors.New("cannot delete a location that still has jams")
+			return router.NewBadRequestError("cannot delete a location that still has jams", nil)
 		}
 		return e.Next()
 	})