test: add functional portal suite

This commit is contained in:
bisco
2026-06-24 09:25:28 +02:00
parent 4886516aa3
commit 1e3685bab8
12 changed files with 358 additions and 4 deletions
+2
View File
@@ -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 check
docker compose run --rm frontend npm run build docker compose run --rm frontend npm run build
docker compose run --rm proxy nginx -t 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 docker compose config --quiet
``` ```
+3
View File
@@ -15,6 +15,9 @@ backend/staticfiles/
frontend/.astro/ frontend/.astro/
frontend/dist/ frontend/dist/
frontend/node_modules/ frontend/node_modules/
tests/functional/node_modules/
tests/functional/test-results/
tests/functional/playwright-report/
backups/ backups/
media-backup/ media-backup/
*.sql *.sql
+20
View File
@@ -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 check
docker compose run --rm frontend npm run build docker compose run --rm frontend npm run build
docker compose run --rm proxy nginx -t 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 docker compose config --quiet
# Stop the stack without deleting content # 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 ├── backend/ # Wagtail/Django CMS and API
├── frontend/ # Astro public website ├── frontend/ # Astro public website
├── nginx/ # Reverse-proxy virtual host ├── nginx/ # Reverse-proxy virtual host
├── tests/functional/ # Playwright browser tests
├── docker-compose.test.yml # Functional-test Compose override
├── docs/ # Architecture, operations, security, and ADRs ├── docs/ # Architecture, operations, security, and ADRs
├── docker-compose.yml ├── docker-compose.yml
├── .env.example ├── .env.example
└── README.md └── 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 and media backups
Database rows and uploaded files must be backed up together: Database rows and uploaded files must be backed up together:
+33
View File
@@ -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:
+4
View File
@@ -22,6 +22,10 @@
- NGINX forwards the original host and standard client/protocol headers. The local - 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 configuration is HTTP-only; production must add TLS and ensure application ports are
not externally reachable. 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 Manual production hardening remains required for reverse proxy headers, TLS, media
storage, backup retention, monitoring, and admin network policy. storage, backup retention, monitoring, and admin network policy.
+13 -4
View File
@@ -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 check
docker compose run --rm frontend npm run build docker compose run --rm frontend npm run build
docker compose run --rm proxy nginx -t 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 docker compose config --quiet
``` ```
@@ -20,12 +22,19 @@ The test suite covers:
- Astro static type and template validation; - Astro static type and template validation;
- production frontend build; - production frontend build;
- NGINX syntax and upstream configuration validation; - NGINX syntax and upstream configuration validation;
- Playwright functional browser tests through the NGINX virtual host;
- Docker Compose configuration validation. - Docker Compose configuration validation.
All commands run in containers. The backend test container starts PostgreSQL through All commands run in containers. The backend test container starts PostgreSQL through
Compose; the frontend checks use the checked-in lockfile for reproducible installs. 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 `docker-compose.test.yml` is an override used only for functional verification. It
NGINX using the `azionelab.org` host. Also verify that an unknown host returns 404. adds an internal `azionelab.org` alias to the proxy and starts a pinned Playwright
Basic keyboard navigation, responsive layout, and accessible names require a manual container. Run the idempotent demo seed through the merged backend service immediately
browser pass until browser automation is added. 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.
+3
View File
@@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/
+13
View File
@@ -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"]
+78
View File
@@ -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"
}
}
}
}
+12
View File
@@ -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"
}
}
+22
View File
@@ -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",
},
});
+155
View File
@@ -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();
});