From 1e3685bab853f5b276622d620f7c5b10c767e766 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 24 Jun 2026 09:25:28 +0200 Subject: [PATCH] test: add functional portal suite --- .codex/project.md | 2 + .gitignore | 3 + README.md | 20 ++++ docker-compose.test.yml | 33 ++++++ docs/security.md | 4 + docs/testing.md | 17 ++- tests/functional/.dockerignore | 3 + tests/functional/Dockerfile | 13 +++ tests/functional/package-lock.json | 78 +++++++++++++ tests/functional/package.json | 12 ++ tests/functional/playwright.config.ts | 22 ++++ tests/functional/tests/portal.spec.ts | 155 ++++++++++++++++++++++++++ 12 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 tests/functional/.dockerignore create mode 100644 tests/functional/Dockerfile create mode 100644 tests/functional/package-lock.json create mode 100644 tests/functional/package.json create mode 100644 tests/functional/playwright.config.ts create mode 100644 tests/functional/tests/portal.spec.ts diff --git a/.codex/project.md b/.codex/project.md index 67ddc68..496743c 100644 --- a/.codex/project.md +++ b/.codex/project.md @@ -79,6 +79,8 @@ docker compose run --rm backend python manage.py test docker compose run --rm frontend npm run check docker compose run --rm frontend npm run build docker compose run --rm proxy nginx -t +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests docker compose config --quiet ``` diff --git a/.gitignore b/.gitignore index 16408a9..8e2e11b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ backend/staticfiles/ frontend/.astro/ frontend/dist/ frontend/node_modules/ +tests/functional/node_modules/ +tests/functional/test-results/ +tests/functional/playwright-report/ backups/ media-backup/ *.sql diff --git a/README.md b/README.md index 056d0d6..c01c6a5 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ docker compose run --rm backend python manage.py test docker compose run --rm frontend npm run check docker compose run --rm frontend npm run build docker compose run --rm proxy nginx -t +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests docker compose config --quiet # Stop the stack without deleting content @@ -128,12 +130,30 @@ local environment; run the seed command again after the next startup. ├── backend/ # Wagtail/Django CMS and API ├── frontend/ # Astro public website ├── nginx/ # Reverse-proxy virtual host +├── tests/functional/ # Playwright browser tests +├── docker-compose.test.yml # Functional-test Compose override ├── docs/ # Architecture, operations, security, and ADRs ├── docker-compose.yml ├── .env.example └── README.md ``` +## Functional tests + +The Compose test override builds a pinned Playwright image, exposes `azionelab.org` on +the internal Docker network, and runs the browser suite against NGINX. It replaces the +database mount with the isolated `test_postgres_data` volume, so the normal CMS data is +not changed. Refresh the idempotent test seed first: + +```bash +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests +``` + +The suite covers page content and section order, contact actions, mobile navigation, +responsive overflow, semantic landmarks, image alternatives, the aggregate API and +media, Wagtail admin routing, and rejection of unknown virtual hosts. + ## Database and media backups Database rows and uploaded files must be backed up together: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..e97922e --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,33 @@ +services: + db: + volumes: + - test_postgres_data:/var/lib/postgresql/data + + backend: + environment: + WAGTAILADMIN_BASE_URL: http://azionelab.org + + proxy: + networks: + default: + aliases: + - azionelab.org + + functional-tests: + build: + context: ./tests/functional + init: true + environment: + BASE_URL: http://azionelab.org + INVALID_HOST_URL: http://proxy + CI: "true" + depends_on: + proxy: + condition: service_healthy + profiles: ["test"] + shm_size: 1gb + security_opt: + - no-new-privileges:true + +volumes: + test_postgres_data: diff --git a/docs/security.md b/docs/security.md index bff3352..9361155 100644 --- a/docs/security.md +++ b/docs/security.md @@ -22,6 +22,10 @@ - NGINX forwards the original host and standard client/protocol headers. The local configuration is HTTP-only; production must add TLS and ensure application ports are not externally reachable. +- The Playwright image is pinned and enabled only through the test Compose profile. It + receives no credentials, publishes no host ports, and tests only the local portal. + The override uses a separate PostgreSQL volume so its seed cannot overwrite normal + CMS content. Manual production hardening remains required for reverse proxy headers, TLS, media storage, backup retention, monitoring, and admin network policy. diff --git a/docs/testing.md b/docs/testing.md index a13eee5..b4081f9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,6 +9,8 @@ docker compose run --rm backend python manage.py test docker compose run --rm frontend npm run check docker compose run --rm frontend npm run build docker compose run --rm proxy nginx -t +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests docker compose config --quiet ``` @@ -20,12 +22,19 @@ The test suite covers: - Astro static type and template validation; - production frontend build; - NGINX syntax and upstream configuration validation; +- Playwright functional browser tests through the NGINX virtual host; - Docker Compose configuration validation. All commands run in containers. The backend test container starts PostgreSQL through Compose; the frontend checks use the checked-in lockfile for reproducible installs. -After seeding, smoke-test `/health/`, `/api/site/home/`, and the public page through -NGINX using the `azionelab.org` host. Also verify that an unknown host returns 404. -Basic keyboard navigation, responsive layout, and accessible names require a manual -browser pass until browser automation is added. +`docker-compose.test.yml` is an override used only for functional verification. It +adds an internal `azionelab.org` alias to the proxy and starts a pinned Playwright +container. Run the idempotent demo seed through the merged backend service immediately +before the browser suite. The override uses `test_postgres_data` instead of the normal +database volume and does not publish additional host ports. + +The browser suite verifies the public content and section order, CTA/contact links, +mobile navigation, horizontal overflow, semantic landmarks, image alternatives, API +contract, uploaded media, Wagtail admin routing, and rejection of unknown hosts. A +manual visual review is still recommended for subjective layout and design quality. diff --git a/tests/functional/.dockerignore b/tests/functional/.dockerignore new file mode 100644 index 0000000..dbd64df --- /dev/null +++ b/tests/functional/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/tests/functional/Dockerfile b/tests/functional/Dockerfile new file mode 100644 index 0000000..5ee22c2 --- /dev/null +++ b/tests/functional/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/playwright:v1.61.1-noble + +WORKDIR /tests +RUN chown pwuser:pwuser /tests + +COPY --chown=pwuser:pwuser package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY --chown=pwuser:pwuser . . + +USER pwuser + +CMD ["npm", "test"] diff --git a/tests/functional/package-lock.json b/tests/functional/package-lock.json new file mode 100644 index 0000000..41dbc5d --- /dev/null +++ b/tests/functional/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "azionelab-functional-tests", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "azionelab-functional-tests", + "version": "0.1.0", + "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/tests/functional/package.json b/tests/functional/package.json new file mode 100644 index 0000000..982b3da --- /dev/null +++ b/tests/functional/package.json @@ -0,0 +1,12 @@ +{ + "name": "azionelab-functional-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/tests/functional/playwright.config.ts b/tests/functional/playwright.config.ts new file mode 100644 index 0000000..1e85503 --- /dev/null +++ b/tests/functional/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: "line", + outputDir: "test-results", + use: { + baseURL: process.env.BASE_URL ?? "http://azionelab.org", + browserName: "chromium", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "off", + }, +}); diff --git a/tests/functional/tests/portal.spec.ts b/tests/functional/tests/portal.spec.ts new file mode 100644 index 0000000..6722ada --- /dev/null +++ b/tests/functional/tests/portal.spec.ts @@ -0,0 +1,155 @@ +import { expect, test } from "@playwright/test"; + +const sectionIds = [ + "inizio", + "laboratorio", + "maestro", + "lezioni", + "spettacoli", + "galleria", + "contatti", +]; + +test("renders the seeded single page in the required order", async ({ page }) => { + await page.goto("/"); + + await expect(page).toHaveTitle(/Azione!Lab/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + "Il teatro diventa presenza.", + ); + await expect(page.getByText("Ernesto Estatico")).toBeVisible(); + await expect( + page.getByText("Via dell'Epomeo 9999, Napoli", { exact: true }), + ).toBeVisible(); + await expect(page.locator(".feature-card")).toHaveCount(3); + await expect(page.locator(".show-card")).toHaveCount(2); + await expect(page.locator(".gallery-item")).toHaveCount(4); + await expect( + page.getByRole("link", { name: "Vieni a conoscerci" }).first(), + ).toHaveAttribute("href", "#contatti"); + await expect(page.getByRole("link", { name: "Guarda la galleria" })).toHaveAttribute( + "href", + "#galleria", + ); + + const verticalPositions = await page.evaluate( + (ids) => + ids.map((id) => { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing section #${id}`); + return element.getBoundingClientRect().top + window.scrollY; + }), + sectionIds, + ); + + expect(verticalPositions).toEqual([...verticalPositions].sort((a, b) => a - b)); +}); + +test("exposes usable contact actions", async ({ page }) => { + await page.goto("/#contatti"); + + await expect(page.getByRole("link", { name: /Scrivi una mail/ })).toHaveAttribute( + "href", + "mailto:ciao@laboratorioteatrale.it", + ); + await expect(page.getByRole("link", { name: /Chiama/ })).toHaveAttribute( + "href", + "tel:+393331234567", + ); + await expect(page.getByRole("link", { name: /WhatsApp/ })).toHaveAttribute( + "href", + "https://wa.me/393331234567", + ); +}); + +test("supports mobile navigation without horizontal overflow", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + + const menu = page.locator("details.mobile-menu"); + await menu.locator("summary").click(); + await expect(menu).toHaveAttribute("open", ""); + await menu.getByRole("link", { name: "Le lezioni" }).click(); + await expect(page).toHaveURL(/#lezioni$/); + + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1); +}); + +test("provides semantic landmarks, image alternatives and keyboard access", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page.getByRole("banner")).toHaveCount(1); + await expect(page.getByRole("main")).toHaveCount(1); + await expect(page.getByRole("contentinfo")).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1); + + const imageAlternatives = await page.locator("img").evaluateAll((images) => + images.map((image) => image.getAttribute("alt")?.trim() ?? ""), + ); + expect(imageAlternatives.length).toBeGreaterThan(0); + expect(imageAlternatives.every(Boolean)).toBe(true); + + const images = page.locator("img"); + for (let index = 0; index < await images.count(); index += 1) { + const image = images.nth(index); + await image.scrollIntoViewIfNeeded(); + await expect + .poll(() => image.evaluate((element) => element.naturalWidth)) + .toBeGreaterThan(0); + } + + await page.keyboard.press("Tab"); + await expect(page.getByRole("link", { name: "Vai al contenuto" })).toBeFocused(); +}); + +test("returns the aggregate API contract and serves CMS media", async ({ request }) => { + const healthResponse = await request.get("/health/"); + expect(healthResponse.ok()).toBe(true); + expect(await healthResponse.json()).toEqual({ status: "ok" }); + + const response = await request.get("/api/site/home/"); + expect(response.ok()).toBe(true); + + const payload = await response.json(); + expect(Object.keys(payload).sort()).toEqual([ + "feature_cards", + "gallery_items", + "homepage", + "lesson_info", + "settings", + "shows", + "teacher", + ]); + expect(payload.settings.laboratory_name).toBe("Azione!Lab"); + expect(payload.feature_cards).toHaveLength(3); + expect(payload.shows).toHaveLength(2); + expect(payload.gallery_items).toHaveLength(4); + + const mediaPath = new URL(payload.homepage.hero_image.url).pathname; + const mediaResponse = await request.get(mediaPath); + expect(mediaResponse.ok()).toBe(true); + expect(mediaResponse.headers()["content-type"]).toMatch(/^image\//); +}); + +test("routes Wagtail admin and rejects unknown virtual hosts", async ({ + page, + playwright, +}) => { + await page.goto("/admin/"); + await expect(page).toHaveURL(/\/admin\/login\/\?next=\/admin\/$/); + await expect(page.locator('input[name="username"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + + const invalidHostContext = await playwright.request.newContext({ + baseURL: process.env.INVALID_HOST_URL ?? "http://proxy", + }); + const invalidHostResponse = await invalidHostContext.get("/"); + expect(invalidHostResponse.status()).toBe(404); + await invalidHostContext.dispose(); +});