generated from bisco/codex-bootstrap
test: add functional portal suite
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
@@ -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"]
|
||||||
Generated
+78
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user