diff --git a/.gitignore b/.gitignore index 8593341..0c4fbe1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ docker-compose.override.yml api/data/*.db api/signed-contracts/*.pdf api/.env +test-results/ +playwright-report/ +node_modules/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dff390c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "lisilou-portfolio", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lisilou-portfolio", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.61.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..510f961 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "lisilou-portfolio", + "version": "1.0.0", + "description": "A lightweight, configurable photography portfolio website designed to integrate with Immich for image hosting and client gallery access.", + "main": "index.js", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jhodgkin/lisilou-portfolio.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/jhodgkin/lisilou-portfolio/issues" + }, + "homepage": "https://github.com/jhodgkin/lisilou-portfolio#readme", + "devDependencies": { + "@playwright/test": "^1.61.1" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..99c8f26 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,26 @@ +// @ts-check +const { defineConfig } = require('@playwright/test'); + +const BASE_URL = process.env.BASE_URL || 'http://192.168.1.192:8080'; +const ADMIN_SECRET = process.env.ADMIN_SECRET || '9yPuNfYjy5kisRKrowhmH42PTeEgH'; + +module.exports = defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { timeout: 8_000 }, + retries: 1, + workers: 1, + reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + use: { + baseURL: BASE_URL, + headless: true, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], + // Expose config values to tests via env + globalSetup: undefined, + env: { ADMIN_SECRET, BASE_URL }, +}); diff --git a/src/index.html b/src/index.html index bba7a1a..7c22a43 100644 --- a/src/index.html +++ b/src/index.html @@ -1537,15 +1537,12 @@ - +
-
-
- - +
diff --git a/tests/api.spec.js b/tests/api.spec.js new file mode 100644 index 0000000..ddee3cf --- /dev/null +++ b/tests/api.spec.js @@ -0,0 +1,194 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +const ADMIN_SECRET = process.env.ADMIN_SECRET || '9yPuNfYjy5kisRKrowhmH42PTeEgH'; +const authHeaders = { Authorization: `Bearer ${ADMIN_SECRET}` }; + +test.describe('Booking API — public routes', () => { + test('GET /api/health → {ok:true}', async ({ request }) => { + const res = await request.get('/api/health'); + expect(res.status()).toBe(200); + expect((await res.json()).ok).toBe(true); + }); + + test('POST /api/bookings creates a booking and returns id', async ({ request }) => { + const res = await request.post('/api/bookings', { + data: { + client_name: 'API Test', + client_email: `api-test-${Date.now()}@test.lisilou`, + client_phone: '555-000-0002', + session_date: '2099-11-15', + session_type: 'individual', + session_length: 'mini', + location: 'studio', + }, + }); + expect(res.status()).toBe(201); + const json = await res.json(); + expect(typeof json.id).toBe('number'); + expect(json.id).toBeGreaterThan(0); + }); + + test('POST /api/bookings with payment_status=pending_confirmation accepted', async ({ request }) => { + const res = await request.post('/api/bookings', { + data: { + client_name: 'Payment Test', + client_email: `pay-test-${Date.now()}@test.lisilou`, + session_date: '2099-12-01', + session_type: 'couple', + session_length: 'full', + location: 'downtown', + payment_status: 'pending_confirmation', + }, + }); + expect(res.status()).toBe(201); + expect((await res.json()).id).toBeGreaterThan(0); + }); + + test('POST /api/bookings with missing required fields returns 400', async ({ request }) => { + const res = await request.post('/api/bookings', { + data: { client_name: 'Incomplete' }, + }); + expect(res.status()).toBe(400); + }); + + test('GET /api/bookings/99999/contract returns 404 for unknown booking', async ({ request }) => { + const res = await request.get('/api/bookings/99999/contract'); + expect(res.status()).toBe(404); + }); + + test('GET /api/contracts/template returns 404 (no template on file yet)', async ({ request }) => { + const res = await request.get('/api/contracts/template'); + // Either 404 (no template uploaded) or 200 (uploaded) — both are valid; just not 500 + expect(res.status()).not.toBe(500); + }); +}); + +test.describe('Admin API — authentication', () => { + test('GET /api/admin/stats without token returns 401 or 503', async ({ request }) => { + const res = await request.get('/api/admin/stats'); + expect([401, 503]).toContain(res.status()); + }); + + test('GET /api/admin/stats with wrong token returns 401', async ({ request }) => { + const res = await request.get('/api/admin/stats', { + headers: { Authorization: 'Bearer wrongtoken' }, + }); + expect(res.status()).toBe(401); + }); + + test('GET /api/admin/stats with correct token returns stats', async ({ request }) => { + const res = await request.get('/api/admin/stats', { headers: authHeaders }); + expect(res.status()).toBe(200); + const json = await res.json(); + expect(typeof json.upcoming).toBe('number'); + expect(typeof json.pending_payment).toBe('number'); + expect(typeof json.confirmed_payment).toBe('number'); + expect(Array.isArray(json.nextUpcoming)).toBe(true); + }); +}); + +test.describe('Admin API — bookings CRUD', () => { + let createdId; + + test('GET /api/admin/bookings returns array', async ({ request }) => { + const res = await request.get('/api/admin/bookings', { headers: authHeaders }); + expect(res.status()).toBe(200); + const json = await res.json(); + expect(Array.isArray(json)).toBe(true); + }); + + test('GET /api/admin/bookings filters by payment_status', async ({ request }) => { + const res = await request.get('/api/admin/bookings?payment_status=pending', { headers: authHeaders }); + expect(res.status()).toBe(200); + const json = await res.json(); + for (const b of json) { + expect(b.payment_status).toBe('pending'); + } + }); + + test('GET /api/admin/bookings?search= filters by name', async ({ request }) => { + const res = await request.get('/api/admin/bookings?search=Smoke+Test', { headers: authHeaders }); + expect(res.status()).toBe(200); + const json = await res.json(); + for (const b of json) { + const matches = b.client_name?.includes('Smoke') || b.client_email?.includes('Smoke'); + expect(matches).toBeTruthy(); + } + }); + + test('PATCH /api/admin/bookings/:id updates payment_status', async ({ request }) => { + // Create a booking to patch + const createRes = await request.post('/api/bookings', { + data: { + client_name: 'Admin Patch Test', + client_email: `patch-${Date.now()}@test.lisilou`, + session_date: '2099-10-10', + session_type: 'family', + session_length: 'full', + location: 'park', + payment_status: 'pending_confirmation', + }, + }); + createdId = (await createRes.json()).id; + + const patchRes = await request.patch(`/api/admin/bookings/${createdId}`, { + headers: authHeaders, + data: { payment_status: 'confirmed' }, + }); + expect(patchRes.status()).toBe(200); + const updated = await patchRes.json(); + expect(updated.payment_status).toBe('confirmed'); + }); + + test('PATCH /api/admin/bookings/:id updates notes', async ({ request }) => { + // Reuse createdId from previous test or create new + const createRes = await request.post('/api/bookings', { + data: { + client_name: 'Notes Test', + client_email: `notes-${Date.now()}@test.lisilou`, + session_date: '2099-09-09', + session_type: 'individual', + session_length: 'mini', + location: 'studio', + }, + }); + const id = (await createRes.json()).id; + + const patchRes = await request.patch(`/api/admin/bookings/${id}`, { + headers: authHeaders, + data: { notes: 'Playwright test note' }, + }); + expect(patchRes.status()).toBe(200); + expect((await patchRes.json()).notes).toBe('Playwright test note'); + }); + + test('GET /api/admin/bookings/:id returns single booking', async ({ request }) => { + const listRes = await request.get('/api/admin/bookings', { headers: authHeaders }); + const bookings = await listRes.json(); + if (!bookings.length) return; // skip if empty DB + + const id = bookings[0].id; + const res = await request.get(`/api/admin/bookings/${id}`, { headers: authHeaders }); + expect(res.status()).toBe(200); + const b = await res.json(); + expect(b.id).toBe(id); + expect(b).toHaveProperty('client_name'); + expect(b).toHaveProperty('session_date'); + }); + + test('GET /api/admin/bookings/99999 returns 404', async ({ request }) => { + const res = await request.get('/api/admin/bookings/99999', { headers: authHeaders }); + expect(res.status()).toBe(404); + }); + + test('GET /api/admin/payments/export returns CSV', async ({ request }) => { + const res = await request.get('/api/admin/payments/export', { headers: authHeaders }); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('text/csv'); + const body = await res.text(); + expect(body).toContain('client_name'); + expect(body).toContain('session_date'); + expect(body).toContain('payment_status'); + }); +}); diff --git a/tests/booking.spec.js b/tests/booking.spec.js new file mode 100644 index 0000000..c4bb9a4 --- /dev/null +++ b/tests/booking.spec.js @@ -0,0 +1,205 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +// Helper: get a date 60 days from now in YYYY-MM-DD +function futureDate(daysAhead = 60) { + const d = new Date(); + d.setDate(d.getDate() + daysAhead); + return d.toISOString().slice(0, 10); +} + +// Helper: open booking modal and fill step 1 (date) +async function openAndPickDate(page, daysAhead = 60) { + await page.locator('button:has-text("Book a Session")').first().click(); + await page.locator('#booking-overlay').waitFor({ state: 'visible' }); + await page.locator('#session-date').fill(futureDate(daysAhead)); +} + +test.describe('Booking wizard', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('opens when Book a Session is clicked', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + await expect(page.locator('#booking-overlay')).toBeVisible(); + }); + + test('shows step 1 (Date) on open', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + await expect(page.locator('#step-1')).toBeVisible(); + await expect(page.locator('[data-step="1"]')).toHaveClass(/active/); + }); + + test('close button dismisses the modal', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + await expect(page.locator('#booking-overlay')).toBeVisible(); + await page.locator('#booking-close').click(); + await expect(page.locator('#booking-overlay')).not.toBeVisible(); + }); + + test('step 1 requires a date to advance', async ({ page }) => { + await page.locator('button:has-text("Book a Session")').first().click(); + // Click Next without a date — should stay on step 1 + await page.locator('#btn-next').click(); + await expect(page.locator('#step-1')).toBeVisible(); + await expect(page.locator('#step-2')).not.toBeVisible(); + }); + + test('step 2 renders session type cards from config', async ({ page }) => { + await openAndPickDate(page); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-2')).toBeVisible(); + const cards = page.locator('#session-type-options .option-card'); + await expect(cards.first()).toBeVisible(); + expect(await cards.count()).toBeGreaterThan(0); + }); + + test('selecting a session type card marks it selected', async ({ page }) => { + await openAndPickDate(page); + await page.locator('#btn-next').click(); + const firstCard = page.locator('#session-type-options .option-card').first(); + await firstCard.click(); + await expect(firstCard).toHaveClass(/selected/); + }); + + test('step 3 shows mini and full length cards with prices', async ({ page }) => { + await openAndPickDate(page); + await page.locator('#btn-next').click(); + await page.locator('#session-type-options .option-card').first().click(); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-3')).toBeVisible(); + await expect(page.locator('.length-card[data-value="mini"]')).toBeVisible(); + await expect(page.locator('.length-card[data-value="full"]')).toBeVisible(); + await expect(page.locator('.length-card[data-value="mini"] .length-card-price')).toContainText('$'); + await expect(page.locator('.length-card[data-value="full"] .length-card-price')).toContainText('$'); + }); + + test('step 4 shows location cards', async ({ page }) => { + await openAndPickDate(page); + await page.locator('#btn-next').click(); + await page.locator('#session-type-options .option-card').first().click(); + await page.locator('#btn-next').click(); + await page.locator('.length-card[data-value="mini"]').click(); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-4')).toBeVisible(); + const locationCards = page.locator('.location-card'); + await expect(locationCards.first()).toBeVisible(); + expect(await locationCards.count()).toBeGreaterThan(0); + }); + + test('clicking a location card selects it and opens detail panel', async ({ page }) => { + await openAndPickDate(page); + await page.locator('#btn-next').click(); + await page.locator('#session-type-options .option-card').first().click(); + await page.locator('#btn-next').click(); + await page.locator('.length-card[data-value="mini"]').click(); + await page.locator('#btn-next').click(); + const firstLocation = page.locator('.location-card').first(); + await firstLocation.click(); + await expect(firstLocation).toHaveClass(/selected/); + await expect(page.locator('.location-detail.open').first()).toBeVisible(); + }); + + test('step 5 renders name, email, phone inputs', async ({ page }) => { + await openAndPickDate(page); + await page.locator('#btn-next').click(); + await page.locator('#session-type-options .option-card').first().click(); + await page.locator('#btn-next').click(); + await page.locator('.length-card[data-value="mini"]').click(); + await page.locator('#btn-next').click(); + await page.locator('.location-card').first().click(); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-5')).toBeVisible(); + await expect(page.locator('#client-name').first()).toBeVisible(); + await expect(page.locator('#client-email').first()).toBeVisible(); + await expect(page.locator('#client-phone').first()).toBeVisible(); + }); + + test('step 5 blocks advance when name/email are empty', async ({ page }) => { + await openAndPickDate(page); + await page.locator('#btn-next').click(); + await page.locator('#session-type-options .option-card').first().click(); + await page.locator('#btn-next').click(); + await page.locator('.length-card[data-value="mini"]').click(); + await page.locator('#btn-next').click(); + await page.locator('.location-card').first().click(); + await page.locator('#btn-next').click(); + // Try advancing with empty fields + await page.locator('#btn-next').click(); + await expect(page.locator('#step-5')).toBeVisible(); + await expect(page.locator('#step-6')).not.toBeVisible(); + }); + + test('full wizard flow submits and shows success screen', async ({ page }) => { + const uniqueEmail = `playwright-${Date.now()}@test.lisilou`; + + await openAndPickDate(page, 65); + + // Step 1 → 2 + await page.locator('#btn-next').click(); + await expect(page.locator('#step-2')).toBeVisible(); + + // Step 2: pick first session type + await page.locator('#session-type-options .option-card').first().click(); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-3')).toBeVisible(); + + // Step 3: mini + await page.locator('.length-card[data-value="mini"]').click(); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-4')).toBeVisible(); + + // Step 4: first location + await page.locator('.location-card').first().click(); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-5')).toBeVisible(); + + // Step 5: client info + await page.locator('#client-name').first().fill('Playwright Test'); + await page.locator('#client-email').first().fill(uniqueEmail); + await page.locator('#client-phone').first().fill('555-000-0001'); + await page.locator('#btn-next').click(); + await expect(page.locator('#step-6')).toBeVisible(); + + // Step 6: contract + await expect(page.locator('#step-6')).toBeVisible(); + // Wait for the async initContractStep to finish loading + await expect(page.locator('#contract-loading')).not.toBeAttached({ timeout: 8_000 }); + // Scroll only the contract-viewer element (not the modal) to bottom via JS, + // then dispatch scroll so the event listener fires and sets contractScrolled = true + await page.locator('#contract-viewer').evaluate(el => { + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event('scroll')); + }); + await page.waitForTimeout(200); + await expect(page.locator('#sig-section')).not.toHaveClass(/locked/, { timeout: 5_000 }); + // Scroll the canvas into view, then draw a signature + await page.locator('#sig-canvas').scrollIntoViewIfNeeded(); + const sigCanvas = page.locator('#sig-canvas'); + const box = await sigCanvas.boundingBox(); + if (box && box.width > 0) { + await page.mouse.move(box.x + 20, box.y + box.height / 2); + await page.mouse.down(); + for (let x = 30; x < box.width - 20; x += 8) { + await page.mouse.move(box.x + x, box.y + box.height / 2); + } + await page.mouse.up(); + } + // Type full name + await page.locator('#contract-name').fill('Playwright Test'); + // scrollIntoViewIfNeeded doesn't work reliably inside a fixed-position modal; + // use JS click to bypass viewport coordinate checks while still firing the click handler + await page.evaluate(() => document.getElementById('btn-next').click()); + + // Step 7: payment + await expect(page.locator('#step-7')).toBeVisible(); + await page.locator('#payment-sent').check(); + await page.locator('#btn-next').click(); + + // Success + await expect(page.locator('#step-success')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('#step-success')).toContainText(/thank|confirm|success|session/i); + }); +}); diff --git a/tests/dashboard.spec.js b/tests/dashboard.spec.js new file mode 100644 index 0000000..f31e83f --- /dev/null +++ b/tests/dashboard.spec.js @@ -0,0 +1,145 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +const ADMIN_SECRET = process.env.ADMIN_SECRET || '9yPuNfYjy5kisRKrowhmH42PTeEgH'; + +test.describe('Admin dashboard — auth', () => { + test('loads the login screen at /dashboard', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page.locator('#login-screen')).toBeVisible(); + await expect(page.locator('#login-screen')).toContainText(/Admin/i); + }); + + test('wrong passphrase shows error message', async ({ page }) => { + await page.goto('/dashboard'); + await page.locator('#login-input').fill('wrongpassphrase'); + await page.locator('button:has-text("Sign In")').click(); + await expect(page.locator('#login-error')).toBeVisible({ timeout: 6_000 }); + }); + + test('correct passphrase shows the app', async ({ page }) => { + await page.goto('/dashboard'); + await page.locator('#login-input').fill(ADMIN_SECRET); + await page.locator('button:has-text("Sign In")').click(); + await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 }); + await expect(page.locator('#login-screen')).not.toBeVisible(); + }); + + test('Enter key submits login form', async ({ page }) => { + await page.goto('/dashboard'); + await page.locator('#login-input').fill(ADMIN_SECRET); + await page.locator('#login-input').press('Enter'); + await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 }); + }); + + test('Sign Out returns to login screen', async ({ page }) => { + await page.goto('/dashboard'); + await page.locator('#login-input').fill(ADMIN_SECRET); + await page.locator('button:has-text("Sign In")').click(); + await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 }); + await page.locator('button:has-text("Sign Out")').click(); + await expect(page.locator('#login-screen')).toBeVisible(); + }); +}); + +test.describe('Admin dashboard — panels', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/dashboard'); + await page.locator('#login-input').fill(ADMIN_SECRET); + await page.locator('button:has-text("Sign In")').click(); + await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 }); + }); + + test('Overview panel shows stat cards', async ({ page }) => { + await expect(page.locator('.stat-card')).toHaveCount(4); + // At least one stat value should be rendered (not just —) + const values = page.locator('.stat-card .value'); + await expect(values.first()).toBeVisible(); + }); + + test('Overview shows next upcoming sessions list', async ({ page }) => { + const list = page.locator('#next-up-list'); + await expect(list).toBeVisible(); + // Should load (not show loading spinner indefinitely) + await expect(list.locator('.loading')).not.toBeVisible({ timeout: 8_000 }); + }); + + test('Calendar panel renders a month grid', async ({ page }) => { + await page.locator('#nav-calendar').click(); + await expect(page.locator('#panel-calendar')).toBeVisible(); + await expect(page.locator('#cal-month-label')).toBeVisible(); + // Grid should have at least 28 day cells + 7 header cells + const days = page.locator('.cal-day'); + const count = await days.count(); + expect(count).toBeGreaterThanOrEqual(28); + }); + + test('Calendar prev/next navigation changes month', async ({ page }) => { + await page.locator('#nav-calendar').click(); + const labelBefore = await page.locator('#cal-month-label').textContent(); + await page.locator('button:has-text("Next")').click(); + const labelAfter = await page.locator('#cal-month-label').textContent(); + expect(labelAfter).not.toBe(labelBefore); + }); + + test('Bookings panel renders table with results', async ({ page }) => { + await page.locator('#nav-bookings').click(); + await expect(page.locator('#panel-bookings')).toBeVisible(); + // Table or empty state should appear + const wrap = page.locator('#bookings-table-wrap'); + await expect(wrap).not.toBeEmpty({ timeout: 8_000 }); + }); + + test('Bookings panel search filter narrows results', async ({ page }) => { + await page.locator('#nav-bookings').click(); + await page.locator('#b-search').fill('Smoke Test'); + await page.waitForTimeout(500); // debounce + const wrap = page.locator('#bookings-table-wrap'); + await expect(wrap).not.toBeEmpty({ timeout: 6_000 }); + // All visible names should include Smoke Test (or empty state) + const cells = page.locator('table tbody td strong'); + const count = await cells.count(); + for (let i = 0; i < count; i++) { + const text = await cells.nth(i).textContent(); + expect(text).toContain('Smoke Test'); + } + }); + + test('Bookings panel clear filter button works', async ({ page }) => { + await page.locator('#nav-bookings').click(); + await page.locator('#b-search').fill('xyz-nobody'); + await page.waitForTimeout(400); + await page.locator('button:has-text("Clear")').click(); + await expect(page.locator('#b-search')).toHaveValue(''); + await expect(page.locator('#bookings-table-wrap')).not.toBeEmpty({ timeout: 6_000 }); + }); + + test('Expanding a booking row shows client and payment details', async ({ page }) => { + await page.locator('#nav-bookings').click(); + const firstRow = page.locator('table tbody tr.expandable').first(); + await expect(firstRow).toBeVisible({ timeout: 8_000 }); + await firstRow.click(); + // Expand row with detail should appear + const expandRow = page.locator('.expand-row').first(); + await expect(expandRow).toBeVisible({ timeout: 5_000 }); + await expect(expandRow).toContainText(/Client|Payment|Session/i); + }); + + test('Payments panel shows revenue summary', async ({ page }) => { + await page.locator('#nav-payments').click(); + await expect(page.locator('#panel-payments')).toBeVisible(); + await expect(page.locator('#revenue-summary')).not.toBeEmpty({ timeout: 8_000 }); + await expect(page.locator('.revenue-card')).toHaveCount(3); + }); + + test('Payments panel CSV export triggers download', async ({ page }) => { + await page.locator('#nav-payments').click(); + + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 10_000 }), + page.locator('button:has-text("Export CSV")').click(), + ]); + + expect(download.suggestedFilename()).toMatch(/lisilou-bookings.*\.csv/); + }); +}); diff --git a/tests/portfolio.spec.js b/tests/portfolio.spec.js new file mode 100644 index 0000000..ba7ec93 --- /dev/null +++ b/tests/portfolio.spec.js @@ -0,0 +1,63 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +test.describe('Portfolio site', () => { + test('loads and shows site name in nav', async ({ page }) => { + await page.goto('/'); + // #header-logo is the visible nav brand link + const logo = page.locator('#header-logo, .logo').filter({ visible: true }).first(); + await expect(logo).toBeVisible(); + await expect(logo).toContainText(/.+/); // non-empty + }); + + test('navigation renders with Book a Session button', async ({ page }) => { + await page.goto('/'); + const bookBtn = page.locator('button:has-text("Book a Session"), a:has-text("Book a Session")').first(); + await expect(bookBtn).toBeVisible(); + }); + + test('portfolio section is visible', async ({ page }) => { + await page.goto('/'); + // Wait for page to load config and render + await page.waitForLoadState('networkidle'); + const portfolio = page.locator('#portfolio, section#portfolio, [id*="portfolio"]').first(); + await expect(portfolio).toBeAttached(); + }); + + test('config/site.json is served', async ({ page }) => { + const res = await page.request.get('/config/site.json'); + expect(res.status()).toBe(200); + const json = await res.json(); + expect(json).toHaveProperty('site'); + expect(json).toHaveProperty('booking'); + expect(json).toHaveProperty('theme'); + }); + + test('health endpoint returns ok', async ({ page }) => { + const res = await page.request.get('/health'); + expect(res.status()).toBe(200); + }); + + test('API health returns {ok:true}', async ({ page }) => { + const res = await page.request.get('/api/health'); + expect(res.status()).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + }); + + test('pink theme is applied (primary color is rose/pink)', async ({ page }) => { + await page.goto('/'); + const primary = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim() + ); + // Should be a pink/rose hex — starts with #B or #b (our dusty rose #B06A7A) + expect(primary.toLowerCase()).toMatch(/^#[a-f0-9]{6}$/i); + // Hue should be in the red-pink range: R > G and R > B + const hex = primary.replace('#', ''); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + expect(r).toBeGreaterThan(g); + expect(r).toBeGreaterThan(b); + }); +});