diff --git a/.gitignore b/.gitignore index c548652..c48f521 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ release/ # Test pipeline data/test/ frontend/test-results/ +frontend/playwright-report/ diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..24d7e03 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from '@playwright/test'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import * as path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load TT_BASE_URL from data/test/.env if not already set +const envFile = path.resolve(__dirname, '../data/test/.env'); +if (fs.existsSync(envFile)) { + for (const line of fs.readFileSync(envFile, 'utf-8').split('\n')) { + const eq = line.indexOf('='); + if (eq > 0) { + const key = line.slice(0, eq).trim(); + if (!process.env[key]) process.env[key] = line.slice(eq + 1).trim(); + } + } +} + +const baseURL = process.env.TT_BASE_URL ?? 'http://127.0.0.1:3000'; + +export default defineConfig({ + globalSetup: './tests/global-setup.ts', + testDir: './tests', + workers: 1, + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ], + use: { + baseURL, + storageState: 'tests/.auth/admin.json', + }, + webServer: { + command: 'make -C .. test-up', + url: `${baseURL}/health`, + reuseExistingServer: true, + timeout: 60_000, + }, +}); diff --git a/frontend/tests/fixtures.ts b/frontend/tests/fixtures.ts new file mode 100644 index 0000000..01f418b --- /dev/null +++ b/frontend/tests/fixtures.ts @@ -0,0 +1,28 @@ +import { test as base, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function getBaseURL(): string { + const envFile = path.resolve(__dirname, '../../../data/test/.env'); + if (fs.existsSync(envFile)) { + for (const line of fs.readFileSync(envFile, 'utf-8').split('\n')) { + if (line.startsWith('TT_BASE_URL=')) return line.slice('TT_BASE_URL='.length).trim(); + } + } + return process.env.TT_BASE_URL ?? 'http://127.0.0.1:3000'; +} + +// Extends base test with a beforeEach that resets DB to clean seed state +export const test = base.extend<{ page: Parameters[1]>[0]['page'] }>({ + page: async ({ page }, use) => { + const baseURL = getBaseURL(); + const res = await page.request.post(`${baseURL}/__test__/reset`); + if (!res.ok()) throw new Error(`DB reset failed: ${res.status()}`); + await use(page); + }, +}); + +export { expect }; diff --git a/frontend/tests/global-setup.ts b/frontend/tests/global-setup.ts new file mode 100644 index 0000000..8041fff --- /dev/null +++ b/frontend/tests/global-setup.ts @@ -0,0 +1,47 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function globalSetup() { + // Read env vars written by scripts/test-env.sh (make test-up calls it first) + const envFile = path.resolve(__dirname, '../../../data/test/.env'); + if (!fs.existsSync(envFile)) { + throw new Error('data/test/.env not found — run "make test-up" first'); + } + for (const line of fs.readFileSync(envFile, 'utf-8').split('\n')) { + const eq = line.indexOf('='); + if (eq > 0) process.env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + + const baseURL = process.env.TT_BASE_URL!; + + // Obtain admin JWT via the login API + const res = await fetch(`${baseURL}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@tutortool.com', password: 'admin' }), + }); + if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`); + const { token, is_superadmin } = await res.json() as { token: string; is_superadmin: boolean }; + + // Write Playwright storage state with localStorage pre-populated + const authDir = path.resolve(__dirname, '.auth'); + fs.mkdirSync(authDir, { recursive: true }); + const storageState = { + cookies: [], + origins: [ + { + origin: baseURL, + localStorage: [ + { name: 'token', value: token }, + { name: 'is_superadmin', value: String(is_superadmin) }, + ], + }, + ], + }; + fs.writeFileSync(path.join(authDir, 'admin.json'), JSON.stringify(storageState, null, 2)); +} + +export default globalSetup; diff --git a/frontend/tests/superadmin.spec.ts b/frontend/tests/superadmin.spec.ts new file mode 100644 index 0000000..92f5adb --- /dev/null +++ b/frontend/tests/superadmin.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from './fixtures'; + +test.describe('Superadmin CRUD & UI Consistency', () => { + test('should show superadmin navigation and theme consistency', async ({ page }) => { + await page.goto('/admin'); + + const tutorsLink = page.locator('nav >> text=Tutor:innen'); + await expect(tutorsLink).toBeVisible(); + + const mainContainer = page.locator('.paper-bg'); + await expect(mainContainer).toBeVisible(); + + const header = page.locator('.serif').first(); + await expect(header).toBeVisible(); + const fontFamily = await header.evaluate(el => window.getComputedStyle(el).fontFamily); + expect(fontFamily).toContain('Source Serif'); + }); + + test('should allow superadmin to navigate to tutors and see the list', async ({ page }) => { + await page.goto('/admin'); + await page.click('nav >> text=Tutor:innen'); + await expect(page).toHaveURL(/\/admin\/tutors/); + + const tableHeader = page.locator('th >> text=Name / E-Mail'); + await expect(tableHeader).toBeVisible(); + + await expect(page.locator('text=Demo Admin')).toBeVisible(); + }); + + test('should allow superadmin to see course assignment UI', async ({ page }) => { + await page.goto('/admin'); + await page.click('nav >> text=Kurse'); + await expect(page).toHaveURL(/\/admin\/courses/); + + await expect(page.locator('text=Neuen Kurs anlegen')).toBeVisible(); + + const tutorSelect = page.locator('select >> text=+ Hinzufügen').first(); + await expect(tutorSelect).toBeVisible(); + }); +});