From 286dbbbcc8b07606c909989fce0bd72d0e465f15 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sun, 22 Mar 2026 13:03:06 +0100 Subject: [PATCH] test: add E2E infrastructure and 5 Playwright test flows Add Playwright E2E testing setup with Docker Compose test environment, database seed script, auth helpers, and five test scenarios covering registration, admin providers, settings persistence, sources CRUD, and settings export/import. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/.gitignore | 5 + e2e/docker-compose.test.yml | 56 +++ e2e/helpers/auth.ts | 145 ++++++ e2e/package-lock.json | 810 ++++++++++++++++++++++++++++++ e2e/package.json | 16 + e2e/playwright.config.ts | 29 ++ e2e/seed.ts | 102 ++++ e2e/tests/admin-providers.spec.ts | 83 +++ e2e/tests/registration.spec.ts | 78 +++ e2e/tests/settings-export.spec.ts | 97 ++++ e2e/tests/settings.spec.ts | 62 +++ e2e/tests/sources.spec.ts | 87 ++++ e2e/tsconfig.json | 15 + 13 files changed, 1585 insertions(+) create mode 100644 e2e/.gitignore create mode 100644 e2e/docker-compose.test.yml create mode 100644 e2e/helpers/auth.ts create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/seed.ts create mode 100644 e2e/tests/admin-providers.spec.ts create mode 100644 e2e/tests/registration.spec.ts create mode 100644 e2e/tests/settings-export.spec.ts create mode 100644 e2e/tests/settings.spec.ts create mode 100644 e2e/tests/sources.spec.ts create mode 100644 e2e/tsconfig.json diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..90d2772 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +blob-report/ diff --git a/e2e/docker-compose.test.yml b/e2e/docker-compose.test.yml new file mode 100644 index 0000000..f419676 --- /dev/null +++ b/e2e/docker-compose.test.yml @@ -0,0 +1,56 @@ +services: + app: + build: + context: ../backend + dockerfile: Dockerfile + container_name: ai-synth-test + restart: "no" + environment: + DATABASE_URL: postgres://ai_synth_test:testpassword@db:5432/ai_synth_test + MASTER_ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000001" + SESSION_SECRET: "e2e-test-session-secret-at-least-64-characters-long-for-security-purposes-here" + APP_URL: http://localhost:8080 + PORT: "8080" + RUST_LOG: "info,ai_synth_backend=debug" + RESEND_API_KEY: "re_test_bypass_no_send" + EMAIL_FROM: "AI Weekly Synth " + TURNSTILE_SECRET_KEY: "test-turnstile-secret-always-pass" + TURNSTILE_SITE_KEY: "1x00000000000000000000AA" + STATIC_DIR: "./static" + depends_on: + db: + condition: service_healthy + networks: + - test-net + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"] + interval: 10s + timeout: 5s + start_period: 30s + retries: 5 + + db: + image: postgres:17-alpine + container_name: ai-synth-test-db + restart: "no" + environment: + POSTGRES_USER: ai_synth_test + POSTGRES_PASSWORD: testpassword + POSTGRES_DB: ai_synth_test + networks: + - test-net + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ai_synth_test -d ai_synth_test"] + interval: 5s + timeout: 3s + start_period: 5s + retries: 5 + shm_size: 128mb + +networks: + test-net: + driver: bridge diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..b90214b --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,145 @@ +/** + * Authentication helpers for E2E tests. + * + * Provides functions to inject session cookies and to perform a full + * registration-and-verify flow through the UI + direct DB access. + */ + +import type { Page } from '@playwright/test'; +import pg from 'pg'; +import { createHash } from 'node:crypto'; + +const { Client } = pg; + +// --------------------------------------------------------------------------- +// Known test tokens — must match seed.ts +// --------------------------------------------------------------------------- +export const ADMIN_SESSION_TOKEN = 'e2e-admin-session-token-for-testing-only'; +export const USER_SESSION_TOKEN = 'e2e-user-session-token-for-testing-only'; +export const SESSION_COOKIE_NAME = 'ai_synth_session'; + +// --------------------------------------------------------------------------- +// Cookie injection helpers +// --------------------------------------------------------------------------- + +/** + * Set the session cookie for the admin test user. + * The page must already be navigated to (or about to navigate to) + * the app domain so the cookie is scoped correctly. + */ +export async function loginAsAdmin(page: Page): Promise { + await page.context().addCookies([ + { + name: SESSION_COOKIE_NAME, + value: ADMIN_SESSION_TOKEN, + domain: 'localhost', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); +} + +/** + * Set the session cookie for the regular test user. + */ +export async function loginAsUser(page: Page): Promise { + await page.context().addCookies([ + { + name: SESSION_COOKIE_NAME, + value: USER_SESSION_TOKEN, + domain: 'localhost', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); +} + +/** + * Perform a full registration flow for a new email address: + * 1. Navigate to /register + * 2. Fill out the form and submit + * 3. Extract the raw magic link token from the DB + * 4. Navigate to the verify URL with that token + * + * This works because: + * - Turnstile is in test mode (auto-passes with the test site key) + * - Resend is in test mode (no email actually sent) + * - We read the token hash from magic_tokens and reverse-lookup is not needed + * because we query by email and find the most recent unused token + * + * NOTE: We cannot reverse-lookup the raw token from its hash. Instead, + * we query the magic_tokens table for the most recent token_hash for + * this email and use the backend's GET /api/v1/auth/verify endpoint + * which sets the cookie via redirect. But since we only have the hash, + * we instead need a workaround: we query the DB for the token_hash, + * then use the POST verify endpoint. + * + * Actually, the cleanest approach is to create a magic link via the + * register API, then query the DB for the token_hash. Since we cannot + * reverse the hash, we insert a known token ourselves after registration. + */ +export async function registerAndVerify( + page: Page, + email: string, +): Promise { + const databaseUrl = + process.env.DATABASE_URL ?? + 'postgres://ai_synth_test:testpassword@localhost:5433/ai_synth_test'; + + // Navigate to register page and fill the form + await page.goto('/register'); + await page.locator('#email').fill(email); + + // Wait for Turnstile to auto-verify (test key always passes) + // The Turnstile widget will call the callback automatically with the test site key. + // We need to wait a moment for the async script to load and render. + await page.waitForTimeout(2000); + + // Submit the form + await page.locator('button[type="submit"]').click(); + + // Wait for the "check inbox" message to appear (means registration succeeded) + await page.waitForSelector('text=Verifiez votre boite de reception', { + timeout: 10_000, + }); + + // Now extract the magic token from the database. + // The token_hash is stored, but we don't have the raw token. + // We'll insert a known magic token for this email directly. + const knownRawToken = `e2e-magic-token-${Date.now()}`; + const knownTokenHash = createHash('sha256') + .update(knownRawToken) + .digest('hex'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + try { + await client.query( + `INSERT INTO magic_tokens (email, token_hash, expires_at, used) + VALUES ($1, $2, $3, false)`, + [email.toLowerCase(), knownTokenHash, expiresAt], + ); + } finally { + await client.end(); + } + + // Navigate to the verify URL (GET endpoint that sets cookie and redirects) + await page.goto(`/api/v1/auth/verify?token=${knownRawToken}`); + + // The GET verify endpoint redirects to APP_URL (http://localhost:8080) + // Wait for the redirect to complete and the home page to load + await page.waitForURL('**/', { timeout: 10_000 }); +} + +/** + * Connect directly to the test database and return a pg Client. + */ +export function createDbClient(): InstanceType { + const databaseUrl = + process.env.DATABASE_URL ?? + 'postgres://ai_synth_test:testpassword@localhost:5433/ai_synth_test'; + return new Client({ connectionString: databaseUrl }); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..d830792 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,810 @@ +{ + "name": "ai-synth-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-synth-e2e", + "devDependencies": { + "@playwright/test": "^1.50.0", + "@types/pg": "^8.11.0", + "pg": "^8.13.0", + "tsx": "^4.21.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..bc537d0 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "ai-synth-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "seed": "tsx seed.ts" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "pg": "^8.13.0", + "tsx": "^4.21.0", + "@types/pg": "^8.11.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..368ff79 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Playwright configuration for AI Weekly Synth E2E tests. + * + * Tests run against the Docker-composed stack on http://localhost:8080. + * A single worker is used to avoid parallel DB state mutations. + */ +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: 1, + workers: 1, + + use: { + baseURL: 'http://localhost:8080', + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { + browserName: 'chromium', + }, + }, + ], +}); diff --git a/e2e/seed.ts b/e2e/seed.ts new file mode 100644 index 0000000..ec9d592 --- /dev/null +++ b/e2e/seed.ts @@ -0,0 +1,102 @@ +/** + * E2E test database seeder. + * + * Creates known test users and sessions in the test Postgres database. + * Designed to be idempotent: uses INSERT ... ON CONFLICT DO NOTHING. + * + * Usage: npx tsx seed.ts + */ + +import pg from 'pg'; +import { createHash } from 'node:crypto'; + +const { Client } = pg; + +// --------------------------------------------------------------------------- +// Known test tokens (raw values that go into cookies / URLs). +// Their SHA-256 hashes are stored in the DB. +// --------------------------------------------------------------------------- +export const ADMIN_SESSION_TOKEN = 'e2e-admin-session-token-for-testing-only'; +export const USER_SESSION_TOKEN = 'e2e-user-session-token-for-testing-only'; + +function sha256(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +async function seed() { + const databaseUrl = + process.env.DATABASE_URL ?? + 'postgres://ai_synth_test:testpassword@localhost:5433/ai_synth_test'; + + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + + try { + // ----------------------------------------------------------------------- + // 1. Create admin user + // ----------------------------------------------------------------------- + const adminId = '00000000-0000-4000-a000-000000000001'; + await client.query( + `INSERT INTO users (id, email, display_name, role, created_at, updated_at) + VALUES ($1, 'admin@test.local', 'Test Admin', 'admin', now(), now()) + ON CONFLICT (id) DO NOTHING`, + [adminId], + ); + + // ----------------------------------------------------------------------- + // 2. Create regular user + // ----------------------------------------------------------------------- + const userId = '00000000-0000-4000-a000-000000000002'; + await client.query( + `INSERT INTO users (id, email, display_name, role, created_at, updated_at) + VALUES ($1, 'user@test.local', 'Test User', 'user', now(), now()) + ON CONFLICT (id) DO NOTHING`, + [userId], + ); + + // ----------------------------------------------------------------------- + // 3. Create sessions (hash tokens with SHA-256 before inserting) + // ----------------------------------------------------------------------- + const adminSessionHash = sha256(ADMIN_SESSION_TOKEN); + const userSessionHash = sha256(USER_SESSION_TOKEN); + + const expiresAt = new Date( + Date.now() + 30 * 24 * 60 * 60 * 1000, + ).toISOString(); // 30 days + + await client.query( + `INSERT INTO sessions (session_hash, user_id, created_at, expires_at, last_active_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (session_hash) DO NOTHING`, + [adminSessionHash, adminId, expiresAt], + ); + + await client.query( + `INSERT INTO sessions (session_hash, user_id, created_at, expires_at, last_active_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (session_hash) DO NOTHING`, + [userSessionHash, userId, expiresAt], + ); + + // ----------------------------------------------------------------------- + // 4. Ensure Gemini provider exists and is enabled + // (The migration seeds it, but make sure is_enabled = true) + // ----------------------------------------------------------------------- + await client.query( + `UPDATE admin_providers SET is_enabled = true WHERE provider_name = 'gemini'`, + ); + + console.log('Seed completed successfully.'); + console.log(` Admin: admin@test.local (id=${adminId})`); + console.log(` User: user@test.local (id=${userId})`); + console.log(` Admin session hash: ${adminSessionHash.slice(0, 16)}...`); + console.log(` User session hash: ${userSessionHash.slice(0, 16)}...`); + } finally { + await client.end(); + } +} + +seed().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/e2e/tests/admin-providers.spec.ts b/e2e/tests/admin-providers.spec.ts new file mode 100644 index 0000000..c93fc73 --- /dev/null +++ b/e2e/tests/admin-providers.spec.ts @@ -0,0 +1,83 @@ +/** + * E2E test: Admin providers management. + * + * Validates that an admin user can: + * 1. Navigate to /admin/providers + * 2. See the Gemini provider card + * 3. Toggle enable if needed + * 4. Save changes + * 5. Verify the provider appears in the settings dropdown + */ + +import { test, expect } from '@playwright/test'; +import { loginAsAdmin } from '../helpers/auth'; + +test.describe('Admin providers', () => { + test('should see Gemini provider and verify it appears in settings', async ({ + page, + }) => { + // Step 1: Login as admin via cookie injection + await loginAsAdmin(page); + + // Step 2: Navigate to admin providers page + await page.goto('/admin/providers'); + + // Step 3: Wait for the page to load and verify the Gemini provider card is visible + await expect( + page.locator('h2', { hasText: 'Google Gemini' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 4: Find the Gemini card. Check if the enable toggle shows "Active" + const geminiCard = page + .locator('.bg-white.shadow-sm.border') + .filter({ hasText: 'Google Gemini' }); + await expect(geminiCard).toBeVisible(); + + // Check the toggle state. If it shows "Desactive", click it to enable. + const statusText = geminiCard.locator('span', { hasText: /Active|Desactive/ }); + const currentStatus = await statusText.textContent(); + + if (currentStatus?.trim() === 'Desactive') { + // Click the toggle switch (role="switch") within the Gemini card + await geminiCard.locator('button[role="switch"]').click(); + // Verify it now shows "Active" + await expect( + geminiCard.locator('span', { hasText: 'Active' }), + ).toBeVisible(); + } + + // Step 5: Click save on the Gemini card + const saveButton = geminiCard.locator('button', { + hasText: 'Enregistrer', + }); + await saveButton.click(); + + // Wait for save confirmation (toast or page update) + // The save triggers a toast; just wait briefly for the API call + await page.waitForTimeout(1000); + + // Step 6: Navigate to settings to verify the provider appears in dropdown + await page.goto('/settings'); + + // Wait for settings page to load + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // The provider should appear in the model dropdown. + // When there's a single enabled provider, it's auto-selected and the + // provider dropdown may be hidden. Check for the model dropdown instead. + // The research model dropdown (#aiModel) should have Gemini model options. + const modelDropdown = page.locator('#aiModel'); + await expect(modelDropdown).toBeVisible({ timeout: 5_000 }); + + // Verify Gemini models are available in the dropdown + const options = modelDropdown.locator('option'); + const optionCount = await options.count(); + expect(optionCount).toBeGreaterThan(0); + + // Check that at least one option mentions a Gemini model + const allText = await modelDropdown.textContent(); + expect(allText).toContain('Gemini'); + }); +}); diff --git a/e2e/tests/registration.spec.ts b/e2e/tests/registration.spec.ts new file mode 100644 index 0000000..2ca8eba --- /dev/null +++ b/e2e/tests/registration.spec.ts @@ -0,0 +1,78 @@ +/** + * E2E test: Registration flow. + * + * Validates the full magic-link registration process: + * 1. Navigate to /register + * 2. Fill and submit the form (Turnstile auto-passes with test site key) + * 3. Extract magic link token from DB + * 4. Navigate to verify URL + * 5. Assert redirect to home with user email visible + */ + +import { test, expect } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { createDbClient } from '../helpers/auth'; + +test.describe('Registration flow', () => { + test('should register a new user and verify via magic link', async ({ + page, + }) => { + const uniqueEmail = `e2e-reg-${Date.now()}@test.local`; + + // Step 1: Navigate to the registration page + await page.goto('/register'); + + // Verify we see the registration form + await expect( + page.locator('h2', { hasText: 'Creer un compte' }), + ).toBeVisible(); + + // Step 2: Fill in the email field + await page.locator('#email').fill(uniqueEmail); + + // Optionally fill display name + await page.locator('#displayName').fill('E2E Test User'); + + // Wait for Turnstile widget to auto-verify with the test site key. + // The Cloudflare test key (1x00000000000000000000AA) always passes immediately. + await page.waitForTimeout(3000); + + // Step 3: Submit the registration form + await page.locator('button[type="submit"]').click(); + + // Wait for success message (form shows "check inbox" screen) + await expect( + page.getByText('Verifiez votre boite de reception'), + ).toBeVisible({ timeout: 10_000 }); + + // Step 4: Create a known magic token in the DB for verification + const knownRawToken = `e2e-reg-token-${Date.now()}`; + const knownTokenHash = createHash('sha256') + .update(knownRawToken) + .digest('hex'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + + const client = createDbClient(); + await client.connect(); + try { + await client.query( + `INSERT INTO magic_tokens (email, token_hash, expires_at, used) + VALUES ($1, $2, $3, false)`, + [uniqueEmail.toLowerCase(), knownTokenHash, expiresAt], + ); + } finally { + await client.end(); + } + + // Step 5: Navigate to the GET verify endpoint with the raw token. + // This endpoint verifies the token, creates a session, sets the cookie, + // and redirects to APP_URL. + await page.goto(`/api/v1/auth/verify?token=${knownRawToken}`); + + // The verify endpoint redirects to the app URL. Wait for the home page. + await page.waitForURL('**/', { timeout: 15_000 }); + + // Step 6: Assert the user email is visible in the navbar + await expect(page.getByText(uniqueEmail)).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/e2e/tests/settings-export.spec.ts b/e2e/tests/settings-export.spec.ts new file mode 100644 index 0000000..ac409b7 --- /dev/null +++ b/e2e/tests/settings-export.spec.ts @@ -0,0 +1,97 @@ +/** + * E2E test: Settings export/import flow. + * + * Validates: + * 1. Set theme to "Export Test" and save + * 2. Export settings (triggers download) + * 3. Change theme to "Changed" and save + * 4. Import the previously exported file + * 5. Assert theme shows "Export Test" again + */ + +import { test, expect } from '@playwright/test'; +import { loginAsUser } from '../helpers/auth'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +test.describe('Settings export/import', () => { + test('should export and re-import settings correctly', async ({ page }) => { + // Step 1: Login as regular user via cookie injection + await loginAsUser(page); + + // Step 2: Navigate to settings + await page.goto('/settings'); + + // Wait for the settings page to load + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 3: Set theme to "Export Test" + const themeInput = page.locator('#theme'); + await themeInput.fill('Export Test'); + + // Save + await page + .locator('button', { hasText: 'Enregistrer les parametres' }) + .click(); + + // Wait for save confirmation + await expect( + page.getByText('Parametres enregistres avec succes'), + ).toBeVisible({ timeout: 5_000 }); + + // Step 4: Click the export button and capture the download + const downloadPromise = page.waitForEvent('download'); + + await page.locator('button', { hasText: 'Exporter' }).first().click(); + + const download = await downloadPromise; + + // Save the downloaded file to a temp location + const downloadPath = path.join( + '/tmp', + `e2e-settings-export-${Date.now()}.json`, + ); + await download.saveAs(downloadPath); + + // Verify the downloaded file contains our theme + const fileContent = fs.readFileSync(downloadPath, 'utf-8'); + const parsed = JSON.parse(fileContent); + expect(parsed.theme).toBe('Export Test'); + + // Step 5: Change theme to "Changed" and save + await themeInput.fill('Changed'); + await page + .locator('button', { hasText: 'Enregistrer les parametres' }) + .click(); + + await expect( + page.getByText('Parametres enregistres avec succes'), + ).toBeVisible({ timeout: 5_000 }); + + // Verify it changed + await expect(themeInput).toHaveValue('Changed'); + + // Step 6: Import the previously downloaded file + // The import button triggers a hidden file input. We need to set the file + // on the hidden input element directly. + const fileInput = page.locator('input[type="file"][accept=".json"]'); + await fileInput.setInputFiles(downloadPath); + + // Wait for import success message + await expect( + page.getByText("Configuration importee avec succes"), + ).toBeVisible({ timeout: 5_000 }); + + // Step 7: Assert the theme input shows "Export Test" again + await expect(themeInput).toHaveValue('Export Test'); + + // Clean up temp file + try { + fs.unlinkSync(downloadPath); + } catch { + // Ignore cleanup errors + } + }); +}); diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts new file mode 100644 index 0000000..7b49a79 --- /dev/null +++ b/e2e/tests/settings.spec.ts @@ -0,0 +1,62 @@ +/** + * E2E test: User settings persistence. + * + * Validates that a user can: + * 1. Navigate to /settings + * 2. Change the theme input + * 3. Save + * 4. Reload the page + * 5. Assert the theme value persisted + */ + +import { test, expect } from '@playwright/test'; +import { loginAsUser } from '../helpers/auth'; + +test.describe('Settings', () => { + test('should save and persist theme setting across page reloads', async ({ + page, + }) => { + // Step 1: Login as regular user via cookie injection + await loginAsUser(page); + + // Step 2: Navigate to settings + await page.goto('/settings'); + + // Wait for the settings page to load + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 3: Change the theme input to "Cybersecurite" + const themeInput = page.locator('#theme'); + await expect(themeInput).toBeVisible(); + + // Clear existing value and type new one + await themeInput.fill('Cybersecurite'); + + // Verify the input has the new value + await expect(themeInput).toHaveValue('Cybersecurite'); + + // Step 4: Click the save button + const saveButton = page.locator('button', { + hasText: 'Enregistrer les parametres', + }); + await saveButton.click(); + + // Wait for save confirmation message + await expect( + page.getByText('Parametres enregistres avec succes'), + ).toBeVisible({ timeout: 5_000 }); + + // Step 5: Reload the page + await page.reload(); + + // Wait for the settings page to reload + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 6: Assert the theme input value is "Cybersecurite" + await expect(page.locator('#theme')).toHaveValue('Cybersecurite'); + }); +}); diff --git a/e2e/tests/sources.spec.ts b/e2e/tests/sources.spec.ts new file mode 100644 index 0000000..8af3c02 --- /dev/null +++ b/e2e/tests/sources.spec.ts @@ -0,0 +1,87 @@ +/** + * E2E test: Sources CRUD. + * + * Validates: + * 1. Adding a single source + * 2. Bulk-importing a source via textarea + * 3. Deleting a source (two-click confirm) + */ + +import { test, expect } from '@playwright/test'; +import { loginAsUser } from '../helpers/auth'; + +test.describe('Sources management', () => { + test('should add, bulk-import, and delete sources', async ({ page }) => { + // Step 1: Login as regular user via cookie injection + await loginAsUser(page); + + // Step 2: Navigate to sources + await page.goto('/sources'); + + // Wait for the sources page to load + await expect( + page.locator('h1', { hasText: 'Sources Personnalisees' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 3: Add a single source + await page.locator('#source-title').fill('Test Blog'); + await page.locator('#source-url').fill('https://test.example.com/blog'); + + // Click the "Ajouter" submit button + await page.locator('button[type="submit"]', { hasText: 'Ajouter' }).click(); + + // Wait for the source to appear in the list + await expect(page.getByText('Test Blog')).toBeVisible({ timeout: 5_000 }); + await expect( + page.getByText('https://test.example.com/blog'), + ).toBeVisible(); + + // Step 4: Bulk import another source via textarea + const bulkTextarea = page.locator('#bulk-import'); + await bulkTextarea.fill('News Site;https://news.example.com'); + + // Click the bulk import button + await page + .locator('button[type="submit"]', { hasText: 'Importer les sources' }) + .click(); + + // Wait for the second source to appear + await expect(page.getByText('News Site')).toBeVisible({ timeout: 5_000 }); + + // Verify we have 2 sources in the list + // Each source is an
  • in the sources list + const sourceItems = page.locator('ul > li').filter({ + has: page.locator('.text-indigo-600'), + }); + await expect(sourceItems).toHaveCount(2); + + // Step 5: Delete the first source (Test Blog) + // Find the source item containing "Test Blog" and click its delete button + const testBlogItem = page.locator('li').filter({ hasText: 'Test Blog' }); + + // First click: enter confirm state + await testBlogItem.locator('button').last().click(); + + // The button text should change to "Confirmer ?" + await expect( + testBlogItem.getByText('Confirmer ?'), + ).toBeVisible({ timeout: 3_000 }); + + // Second click: confirm delete + await testBlogItem.getByText('Confirmer ?').click(); + + // Wait for the source to be removed from the list + await expect(page.getByText('Test Blog')).not.toBeVisible({ + timeout: 5_000, + }); + + // Step 6: Verify only 1 source remains + const remainingItems = page.locator('ul > li').filter({ + has: page.locator('.text-indigo-600'), + }); + await expect(remainingItems).toHaveCount(1); + + // The remaining source should be "News Site" + await expect(page.getByText('News Site')).toBeVisible(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..b045ec9 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["*.ts", "tests/**/*.ts", "helpers/**/*.ts"] +}