- Compte en lecture seule : aucune modification n’est possible.
-
- ) : null}
);
}
diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts
new file mode 100644
index 0000000..870c842
--- /dev/null
+++ b/e2e/auth.spec.ts
@@ -0,0 +1,52 @@
+import { expect, resetDatabase, signIn, test } from './fixtures';
+
+test.beforeEach(async () => {
+ await resetDatabase();
+});
+
+test('an anonymous visitor is sent to the sign-in screen', async ({ page }) => {
+ await page.goto('/admin/horaires');
+ await expect(page).toHaveURL(/\/login/);
+ await expect(page.getByRole('button', { name: /Se connecter/ })).toBeVisible();
+});
+
+test('an anonymous fetch of an admin route gets a status, not a login page', async ({ request }) => {
+ const response = await request.post('/api/admin/holidays/sync');
+ expect(response.status()).toBe(401);
+});
+
+test('an administrator reaches the dashboard', async ({ page }) => {
+ await signIn(page, 'admin');
+ await page.goto('/admin');
+ await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
+ // The header also carries the address but hides it on a narrow screen, so
+ // the assertion targets the line under the heading, which is always shown.
+ await expect(page.getByText(/Connecté en tant que admin@ita-ito\.test/)).toBeVisible();
+});
+
+test('a read-only account can look but not change', async ({ page }) => {
+ await signIn(page, 'viewer');
+ await page.goto('/admin/horaires');
+
+ await expect(page.getByText(/lecture seule/i).first()).toBeVisible();
+ // The save button is not merely disabled; it is not rendered at all.
+ await expect(page.getByRole('button', { name: 'Enregistrer' })).toHaveCount(0);
+});
+
+test('a read-only account is refused a write by the API', async ({ page, request }) => {
+ await signIn(page, 'viewer');
+ const cookies = await page.context().cookies();
+ const response = await request.post('/api/admin/holidays/sync', {
+ headers: { cookie: cookies.map((c) => `${c.name}=${c.value}`).join('; ') },
+ });
+ expect(response.status()).toBe(403);
+});
+
+test('a read-only account can still sign out', async ({ page }) => {
+ // Regression: gating page POSTs by role broke every form for viewers,
+ // sign-out included.
+ await signIn(page, 'viewer');
+ await page.goto('/admin');
+ await page.getByRole('button', { name: 'Se déconnecter' }).click();
+ await expect(page).toHaveURL(/\/login/);
+});
diff --git a/e2e/constants.ts b/e2e/constants.ts
new file mode 100644
index 0000000..ca53e72
--- /dev/null
+++ b/e2e/constants.ts
@@ -0,0 +1,13 @@
+/**
+ * Values the test process and the server under test must agree on.
+ *
+ * The session cookie is signed on one side and verified on the other, so a
+ * secret that differs between them fails every signed-in test at once while
+ * looking like an authorisation bug. Defining it here, and importing it from
+ * both the Playwright config and the fixtures, removes the possibility.
+ */
+export const E2E_AUTH_SECRET =
+ process.env.E2E_AUTH_SECRET ?? 'e2e-secret-not-used-anywhere-else';
+
+export const E2E_PORT = 3020;
+export const E2E_BASE_URL = `http://127.0.0.1:${E2E_PORT}`;
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
new file mode 100644
index 0000000..cec14a4
--- /dev/null
+++ b/e2e/fixtures.ts
@@ -0,0 +1,114 @@
+import { randomUUID } from 'node:crypto';
+
+import { encode } from '@auth/core/jwt';
+import { test as base, type Page } from '@playwright/test';
+import { Pool } from 'pg';
+
+import { generateDeviceToken, hashToken } from '../lib/device/auth';
+import { E2E_AUTH_SECRET } from './constants';
+
+/**
+ * Database access for the end-to-end suite, over plain SQL.
+ *
+ * Deliberately not the Prisma client: it is generated as a TypeScript module
+ * whose format Playwright's loader and Next's bundler disagree about, and the
+ * suite only needs to empty a handful of tables and read a few rows back.
+ * Raw SQL costs a few lines and removes the argument entirely.
+ */
+
+let pool: Pool | undefined;
+
+function db(): Pool {
+ pool ??= new Pool({ connectionString: process.env.TEST_DATABASE_URL });
+ return pool;
+}
+
+export async function query>(
+ text: string,
+ values: unknown[] = [],
+): Promise {
+ const result = await db().query(text, values);
+ return result.rows as T[];
+}
+
+/**
+ * Signing in without Authentik.
+ *
+ * Mints the session cookie Auth.js would have issued after a successful round
+ * trip. What is under test is the application's behaviour for a given role;
+ * the OIDC handshake is verified against the live provider separately, and
+ * standing up an identity provider per test run would trade a lot of
+ * machinery for coverage of somebody else's code.
+ */
+const COOKIE_NAME = 'authjs.session-token';
+
+export type Role = 'admin' | 'viewer';
+
+export async function signIn(page: Page, role: Role = 'admin'): Promise {
+ const token = await encode({
+ secret: E2E_AUTH_SECRET,
+ // Auth.js derives its encryption key from the cookie name.
+ salt: COOKIE_NAME,
+ maxAge: 3600,
+ token: {
+ sub: `e2e-${role}`,
+ name: role === 'admin' ? 'Admin E2E' : 'Viewer E2E',
+ email: `${role}@ita-ito.test`,
+ role,
+ groups: role === 'admin' ? ['horaires-admins'] : ['autre-groupe'],
+ },
+ });
+
+ await page.context().addCookies([
+ { name: COOKIE_NAME, value: token, domain: '127.0.0.1', path: '/', httpOnly: true },
+ ]);
+}
+
+const WEEK: [number, boolean, string][] = [
+ [0, true, '[]'],
+ [1, true, '[]'],
+ [2, false, '[{"open":"10:00","close":"18:30"}]'],
+ [3, false, '[{"open":"10:00","close":"18:30"}]'],
+ [4, false, '[{"open":"10:00","close":"18:30"}]'],
+ [5, false, '[{"open":"10:00","close":"18:30"}]'],
+ [6, false, '[{"open":"10:00","close":"18:30"}]'],
+];
+
+/** Empties everything and restores a known week. */
+export async function resetDatabase(): Promise {
+ await query(`
+ TRUNCATE device_logs, devices, screen_images, schedule_exceptions, vacation_periods,
+ public_holidays, messages, translation_cache, audit_logs, weekly_schedules,
+ settings, sync_states RESTART IDENTITY CASCADE
+ `);
+
+ await query(`INSERT INTO settings (id, "updatedAt") VALUES ('singleton', now())`);
+
+ for (const [dayOfWeek, isClosed, slots] of WEEK) {
+ await query(
+ `INSERT INTO weekly_schedules (id, "dayOfWeek", "isClosed", slots) VALUES ($1, $2, $3, $4::jsonb)`,
+ [randomUUID(), dayOfWeek, isClosed, slots],
+ );
+ }
+}
+
+/** Pairs a panel and hands back its token, so the device API can be called. */
+export async function pairDevice(): Promise {
+ const token = generateDeviceToken();
+ await query(
+ `INSERT INTO devices (id, "macAddress", "friendlyId", "apiKeyHash", "updatedAt")
+ VALUES ($1, $2, $3, $4, now())`,
+ [randomUUID(), 'E2:E0:00:00:00:01', 'E2E001', hashToken(token)],
+ );
+ return token;
+}
+
+/** Today as the shop reads it, which is not necessarily as the runner does. */
+export function today(offsetDays = 0): string {
+ return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Zurich' }).format(
+ new Date(Date.now() + offsetDays * 86_400_000),
+ );
+}
+
+export const test = base;
+export { expect } from '@playwright/test';
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
new file mode 100644
index 0000000..40df3c1
--- /dev/null
+++ b/e2e/global-setup.ts
@@ -0,0 +1,24 @@
+import 'dotenv/config';
+
+import { E2E_AUTH_SECRET } from './constants';
+
+/**
+ * Prepares the environment the test process itself runs in.
+ *
+ * Loading .env is convenient for TEST_DATABASE_URL, but it also brings the
+ * real AUTH_SECRET along — which would then differ from the one the server
+ * under test was started with, and every signed-in test would fail looking
+ * like an authorisation bug. Pinning it here keeps both sides in agreement.
+ *
+ * The suite truncates every table, so an unset TEST_DATABASE_URL is a hard
+ * stop rather than a fallback to whatever DATABASE_URL happens to point at.
+ */
+export default function globalSetup(): void {
+ if (!process.env.TEST_DATABASE_URL) {
+ throw new Error(
+ 'TEST_DATABASE_URL n’est pas défini. Les tests E2E vident la base : ils refusent de tourner sans base dédiée.',
+ );
+ }
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
+ process.env.AUTH_SECRET = E2E_AUTH_SECRET;
+}
diff --git a/e2e/messages.spec.ts b/e2e/messages.spec.ts
new file mode 100644
index 0000000..cbf0ce2
--- /dev/null
+++ b/e2e/messages.spec.ts
@@ -0,0 +1,61 @@
+import { expect, query, resetDatabase, signIn, test } from './fixtures';
+
+test.beforeEach(async () => {
+ await resetDatabase();
+});
+
+test('a message is saved even when the translation service is unavailable', async ({ page }) => {
+ // The E2E server has no translation credentials on purpose: losing the
+ // message because the service is down would be the worst failure here.
+ await signIn(page, 'admin');
+ await page.goto('/admin/messages');
+
+ // Deliberately not the field's own placeholder text: matching that would
+ // make the assertion pass before anything had been saved.
+ const text = 'Nouvelle collection de printemps';
+
+ await page.getByLabel(/Texte en français/).fill(text);
+ await page.getByRole('button', { name: 'Ajouter' }).click();
+
+ // Wait for the server's own verdict rather than for the text to appear: it
+ // is the only signal that the write has actually completed.
+ await expect(page.getByRole('status')).toContainText(/Message enregistré/);
+ await expect(page.getByText(text).first()).toBeVisible();
+
+ const [stored] = await query<{ textFr: string; translationStatus: string }>(
+ 'SELECT "textFr", "translationStatus" FROM messages',
+ );
+ expect(stored?.textFr).toBe(text);
+ // The E2E server has no translation credentials on purpose: the message
+ // must survive the service being unavailable.
+ expect(stored?.translationStatus).toBe('ERROR');
+});
+
+test('a translation typed by hand is kept and shown', async ({ page }) => {
+ await signIn(page, 'admin');
+ await page.goto('/admin/messages');
+
+ await page.getByLabel(/Texte en français/).fill('Fermeture exceptionnelle');
+ await page.getByRole('button', { name: 'Ajouter' }).click();
+ await expect(page.getByRole('status')).toContainText(/Message enregistré/);
+
+ await page.getByLabel('Traduction anglaise').fill('Exceptionally closed');
+ await page.getByRole('button', { name: 'Enregistrer' }).click();
+
+ await expect(page.getByText(/ne sera plus écrasée/)).toBeVisible();
+
+ const [stored] = await query<{ textEn: string; translationStatus: string }>(
+ 'SELECT "textEn", "translationStatus" FROM messages',
+ );
+ expect(stored?.textEn).toBe('Exceptionally closed');
+ expect(stored?.translationStatus).toBe('MANUAL');
+});
+
+test('the counter warns before a message stops fitting', async ({ page }) => {
+ await signIn(page, 'admin');
+ await page.goto('/admin/messages');
+
+ await page.getByLabel(/Texte en français/).fill('a'.repeat(130));
+ await expect(page.getByText(/caractères de trop/)).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Ajouter' })).toBeDisabled();
+});
diff --git a/e2e/schedule.spec.ts b/e2e/schedule.spec.ts
new file mode 100644
index 0000000..df46658
--- /dev/null
+++ b/e2e/schedule.spec.ts
@@ -0,0 +1,102 @@
+import { expect, pairDevice, query, resetDatabase, signIn, test, today } from './fixtures';
+
+async function panelFilename(request: import('@playwright/test').APIRequestContext, token: string) {
+ const response = await request.get('/api/display', { headers: { 'Access-Token': token } });
+ expect(response.ok()).toBeTruthy();
+ return ((await response.json()) as { filename: string }).filename;
+}
+
+test.beforeEach(async () => {
+ await resetDatabase();
+});
+
+test('changing today’s hours reaches the panel', async ({ page, request }) => {
+ const token = await pairDevice();
+ const before = await panelFilename(request, token);
+
+ await signIn(page, 'admin');
+ await page.goto('/admin/exceptions');
+
+ await page.getByRole('button', { name: /Fermé aujourd’hui/ }).click();
+ await expect(page.getByText(/La boutique est fermée aujourd’hui/)).toBeVisible();
+
+ // Stored as a manual exception for today, in the shop's timezone.
+ // Read as text: node-postgres parses a DATE column into a local-midnight
+ // Date, so toISOString() shifts it back a day at UTC+2. The stored value is
+ // right; the interpretation would not be.
+ const [exception] = await query<{ date: string; isClosed: boolean }>(
+ 'SELECT date::text AS date, "isClosed" FROM schedule_exceptions',
+ );
+ expect(exception?.date).toBe(today());
+ expect(exception?.isClosed).toBe(true);
+
+ // And the panel is handed a different image, so it will redraw.
+ expect(await panelFilename(request, token)).not.toBe(before);
+
+ await page.goto('/admin');
+ await expect(page.getByText(/aujourd’hui/).first()).toBeVisible();
+});
+
+test('the reference week can be edited and shows what will change', async ({ page }) => {
+ await signIn(page, 'admin');
+ await page.goto('/admin/horaires');
+
+ // Monday is closed in the seeded week; open it.
+ await page.getByRole('checkbox', { name: /Ouvert le lundi/i }).check();
+ await expect(page.getByText(/Lundi sera modifié|Sera modifié|1 jour/i).first()).toBeVisible();
+
+ await page.getByRole('button', { name: 'Enregistrer' }).click();
+ await expect(page.getByText(/Horaires enregistrés|enregistrés/i)).toBeVisible();
+
+ const [monday] = await query<{ isClosed: boolean }>(
+ 'SELECT "isClosed" FROM weekly_schedules WHERE "dayOfWeek" = 1',
+ );
+ expect(monday?.isClosed).toBe(false);
+});
+
+test('a closure period closes the days it covers', async ({ page }) => {
+ const start = today();
+ const end = today(3);
+
+ await signIn(page, 'admin');
+ await page.goto('/admin/vacances');
+
+ await page.getByLabel('Du').fill(start);
+ await page.getByLabel('Au (inclus)').fill(end);
+ await page.getByLabel(/Libellé/).fill('Congés de test');
+ await page.getByRole('button', { name: 'Ajouter' }).click();
+
+ await expect(page.getByText(/Période de fermeture enregistrée/)).toBeVisible();
+ await expect(page.getByText('Congés de test')).toBeVisible();
+
+ // The resolver reads the period directly, so the dashboard shows the days
+ // closed without anything having been written into the exception table.
+ await page.goto('/admin');
+ await expect(page.getByText('Congés de test').first()).toBeVisible();
+ const [{ count }] = await query<{ count: string }>('SELECT count(*) FROM schedule_exceptions');
+ expect(Number(count)).toBe(0);
+});
+
+test('overlapping periods are refused by name', async ({ page }) => {
+ const start = today();
+ const end = today(5);
+
+ await signIn(page, 'admin');
+ await page.goto('/admin/vacances');
+
+ await page.getByLabel('Du').fill(start);
+ await page.getByLabel('Au (inclus)').fill(end);
+ await page.getByLabel(/Libellé/).fill('Première');
+ await page.getByRole('button', { name: 'Ajouter' }).click();
+ await expect(page.getByText(/enregistrée/)).toBeVisible();
+ // The form empties itself once the server answers; refilling before that
+ // would have the reset wipe what was just typed.
+ await expect(page.getByLabel(/Libellé/)).toHaveValue('');
+
+ await page.getByLabel('Du').fill(start);
+ await page.getByLabel('Au (inclus)').fill(end);
+ await page.getByLabel(/Libellé/).fill('Seconde');
+ await page.getByRole('button', { name: 'Ajouter' }).click();
+
+ await expect(page.getByText(/chevauche « Première »/)).toBeVisible();
+});
diff --git a/package-lock.json b/package-lock.json
index 746b468..8e91960 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"@eslint/js": "^9.39.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
+ "@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/node-cron": "^3.0.11",
@@ -2178,6 +2179,22 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@prisma/adapter-pg": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
@@ -8949,6 +8966,35 @@
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
"license": "MIT"
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
diff --git a/package.json b/package.json
index 282aea0..87bb39d 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"brand": "tsx scripts/brand.ts",
"e2e": "playwright test",
"holidays:sync": "tsx scripts/sync-holidays.ts",
- "translate:test": "tsx scripts/test-translation.ts"
+ "translate:test": "tsx scripts/test-translation.ts",
+ "e2e:ui": "playwright test --ui"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
@@ -45,6 +46,7 @@
"@eslint/js": "^9.39.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
+ "@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/node-cron": "^3.0.11",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..57b1b80
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,80 @@
+// Loaded here, not in globalSetup: this file is evaluated first, and the
+// webServer environment below is read from it. Without this, TEST_DATABASE_URL
+// is still undefined when the server is started and every page fails on a
+// missing DATABASE_URL.
+import 'dotenv/config';
+
+import { defineConfig, devices } from '@playwright/test';
+
+import { E2E_AUTH_SECRET, E2E_BASE_URL, E2E_PORT } from './e2e/constants';
+
+/**
+ * End-to-end tests.
+ *
+ * They run against a real server and a real database — the E2E database, which
+ * the global setup empties. That is the whole point: these are the only tests
+ * that exercise the middleware, the server actions and the rendering together,
+ * the way a person in the shop does.
+ *
+ * Port 3020 so a development server on 3010 can stay running.
+ */
+const PORT = E2E_PORT;
+const BASE_URL = E2E_BASE_URL;
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: false,
+ workers: 1,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['github'], ['list']] : 'list',
+ globalSetup: './e2e/global-setup.ts',
+
+ use: {
+ baseURL: BASE_URL,
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ locale: 'fr-CH',
+ timezoneId: 'Europe/Zurich',
+ },
+
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ {
+ // The shop uses a phone behind the counter, so the narrow viewport is
+ // the primary case, not an afterthought.
+ //
+ // Run on Chromium rather than WebKit: WebKit needs system packages that
+ // only root can install, and a suite nobody can run locally is a suite
+ // nobody runs. This still covers the layout, the touch targets and the
+ // mobile keyboard types. To exercise the real Safari engine — worth
+ // doing before trusting an iPhone-only bug report — install the
+ // dependencies once (`sudo npx playwright install-deps webkit`) and drop
+ // the browserName override.
+ name: 'mobile',
+ use: { ...devices['iPhone 15'], browserName: 'chromium' },
+ },
+ ],
+
+ webServer: {
+ // A production build rather than the dev server: it is what actually ships,
+ // and Next refuses to start a second dev server in the same directory, so
+ // this also lets the suite run while someone is developing.
+ command: 'sh scripts/e2e-server.sh',
+ url: `${BASE_URL}/api/health`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ env: {
+ PORT: String(PORT),
+ NODE_ENV: 'production',
+ // Never the development database: the setup empties this one.
+ DATABASE_URL: process.env.TEST_DATABASE_URL ?? '',
+ AUTH_URL: BASE_URL,
+ AUTH_SECRET: E2E_AUTH_SECRET,
+ AUTHENTIK_ADMIN_GROUP: 'horaires-admins',
+ // The translation service is stubbed per-test; never called for real.
+ TRANSLATION_API_URL: '',
+ TRANSLATION_API_KEY: '',
+ },
+ },
+});
diff --git a/scripts/e2e-server.sh b/scripts/e2e-server.sh
new file mode 100755
index 0000000..8aef385
--- /dev/null
+++ b/scripts/e2e-server.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Builds and serves the application exactly as the container does.
+#
+# `next start` refuses to work with output: standalone, and running the
+# standalone server directly is what the Dockerfile does anyway — so the E2E
+# suite exercises the same artifact shape that ships, rather than a second
+# arrangement that could drift from it.
+set -e
+
+npm run build
+
+# The standalone bundle does not carry these; the Dockerfile copies them too.
+rm -rf .next/standalone/.next/static .next/standalone/public
+cp -r .next/static .next/standalone/.next/static
+cp -r public .next/standalone/public
+
+exec node .next/standalone/server.js