fix: stabilize e2e suite and align dev runtime config (#408)

* fix: stabilize e2e suite and align dev runtime config

* fix: harden forbidden-settings e2e assertion

* fix: make forbidden settings e2e assertion robust
This commit is contained in:
Daniel Volz
2026-03-10 06:25:46 +01:00
committed by GitHub
parent 2db49e427a
commit 733fe2f38a
13 changed files with 248 additions and 83 deletions
+5
View File
@@ -12,6 +12,7 @@ PGID=1000
PORT=3000 PORT=3000
CORS_ORIGINS=http://localhost:4174 CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=warn LOG_LEVEL=warn
# Levels: debug, info, warn, error, silent # Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker), # Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection) # and frontend browser console (via build-time injection)
@@ -28,6 +29,10 @@ LOG_LEVEL=warn
# Increase for development/testing environments # Increase for development/testing environments
# RATE_LIMIT_MAX=100 # RATE_LIMIT_MAX=100
# API documentation UI + OpenAPI JSON
# Default behavior: enabled outside production, disabled in production
# OPENAPI_DOCS_ENABLED=true
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York) # Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
TZ=Europe/Berlin TZ=Europe/Berlin
+67 -48
View File
@@ -1,49 +1,68 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "E2E stable", "label": "E2E stable",
"type": "shell", "type": "shell",
"command": "npm", "command": "npm",
"args": ["run", "test:e2e"], "args": [
"options": { "run",
"cwd": "${workspaceFolder}/frontend" "test:e2e"
}, ],
"group": "test", "options": {
"problemMatcher": [] "cwd": "${workspaceFolder}/frontend"
}, },
{ "group": "test",
"label": "E2E stable + merged video", "problemMatcher": []
"type": "shell", },
"command": "npm", {
"args": ["run", "test:e2e:with-video"], "label": "E2E stable + merged video",
"options": { "type": "shell",
"cwd": "${workspaceFolder}/frontend" "command": "npm",
}, "args": [
"group": "test", "run",
"problemMatcher": [] "test:e2e:with-video"
}, ],
{ "options": {
"label": "E2E all browsers", "cwd": "${workspaceFolder}/frontend"
"type": "shell", },
"command": "npm", "group": "test",
"args": ["run", "test:e2e:all"], "problemMatcher": []
"options": { },
"cwd": "${workspaceFolder}/frontend" {
}, "label": "E2E all browsers",
"group": "test", "type": "shell",
"problemMatcher": [] "command": "npm",
}, "args": [
{ "run",
"label": "E2E all browsers + merged video", "test:e2e:all"
"type": "shell", ],
"command": "npm", "options": {
"args": ["run", "test:e2e:all:with-video"], "cwd": "${workspaceFolder}/frontend"
"options": { },
"cwd": "${workspaceFolder}/frontend" "group": "test",
}, "problemMatcher": []
"group": "test", },
"problemMatcher": [] {
} "label": "E2E all browsers + merged video",
] "type": "shell",
} "command": "npm",
"args": [
"run",
"test:e2e:all:with-video"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E stable non-interactive",
"type": "shell",
"command": "cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1",
"isBackground": false,
"group": "test"
}
]
}
+45 -16
View File
@@ -177,7 +177,7 @@ The easiest way to deploy MedAssist-ng is with Docker Compose:
git clone https://github.com/DanielVolz/medassist-ng.git git clone https://github.com/DanielVolz/medassist-ng.git
cd medassist-ng cd medassist-ng
cp .env.example .env cp .env.example .env
docker compose up -d docker compose -p medassist-ng up -d
``` ```
Open `http://localhost:4174` and start tracking your medications. Open `http://localhost:4174` and start tracking your medications.
@@ -195,6 +195,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
| `PORT` | `3000` | Backend API port | | `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS | | `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. | | `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders | | `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
### Authentication ### Authentication
@@ -211,6 +212,42 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
Generate secrets with: `openssl rand -hex 32` Generate secrets with: `openssl rand -hex 32`
### API Keys (Programmatic API Access)
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
```bash
Authorization: Bearer ma_...
```
Available scopes:
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
- `write`: read + write access
Essential notes:
- Create keys in the app when authentication is enabled.
- The token is shown only once after creation.
- Creating a new key automatically deactivates previously active keys for the same user.
- API keys are stored hashed in the database.
Example usage:
```bash
curl http://localhost:3000/settings \
-H "Authorization: Bearer ma_..."
```
API reference:
- Interactive docs: `/docs`
- OpenAPI JSON: `/docs/json`
- Key management endpoints for authenticated users:
- `GET /auth/api-keys`
- `POST /auth/api-keys`
- `DELETE /auth/api-keys/:id`
### OIDC / SSO ### OIDC / SSO
| Variable | Default | Description | | Variable | Default | Description |
@@ -309,30 +346,22 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
# Development # Development
```bash ```bash
docker compose -f docker-compose.dev.yml up docker compose -p medassist-dev -f docker-compose.dev.yml up
``` ```
- Frontend: `http://localhost:5173` (hot reload) - Frontend: `http://localhost:5173` (hot reload)
- Backend: `http://localhost:3000` - Backend: `http://localhost:3000`
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
Playwright E2E recommendations: Useful local commands:
```bash ```bash
cd frontend npm run lint
npm run test:e2e:local # local run with PLAYWRIGHT_WORKERS=4 cd backend && npm run test:run
npm run test:e2e:all:local # local all-browser run with PLAYWRIGHT_WORKERS=4 cd frontend && npm run test:run
``` ```
- CI stays at `PLAYWRIGHT_WORKERS=1` for stability.
- Data-heavy specs remain sequential via the `chromium-data` project config.
# Dependency Updates
- Dependabot checks dependencies weekly for `frontend`, `backend`, repository root tooling, and GitHub Actions.
- Minor and patch updates are grouped to reduce PR noise.
- Dependabot minor/patch PRs are configured for auto-merge after required CI checks pass.
- Major updates still require manual review before merge.
# Acknowledgements # Acknowledgements
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic. This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
+4
View File
@@ -1,3 +1,5 @@
name: medassist-dev
services: services:
backend-dev: backend-dev:
image: node:22-slim image: node:22-slim
@@ -10,6 +12,7 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
- NODE_ENV=development
- DATA_DIR=/app/data - DATA_DIR=/app/data
- RATE_LIMIT_MAX=1000 - RATE_LIMIT_MAX=1000
ports: ports:
@@ -33,6 +36,7 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
- NODE_ENV=development
- BACKEND_URL=http://backend-dev:3000 - BACKEND_URL=http://backend-dev:3000
ports: ports:
- "5173:5173" - "5173:5173"
+2
View File
@@ -1,3 +1,5 @@
name: medassist-ng
services: services:
backend: backend:
image: ghcr.io/danielvolz/medassist-ng-backend:latest image: ghcr.io/danielvolz/medassist-ng-backend:latest
+3
View File
@@ -117,6 +117,9 @@ test.describe("Dashboard with medications", () => {
test("should show day summary with dose progress", async ({ page }) => { test("should show day summary with dose progress", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_1)).toBeVisible({ timeout: 10000 });
const todayBlock = page.locator(".day-block.today"); const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible({ timeout: 10000 }); await expect(todayBlock).toBeVisible({ timeout: 10000 });
+9
View File
@@ -427,6 +427,15 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1))); await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
continue; continue;
} }
if (res.status === 400) {
const text = await res.text();
if (text.includes('"code":"NO_MEDICATIONS"') && attempt < 4) {
// Freshly seeded E2E medication data can lag briefly behind the share lookup.
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
continue;
}
throw new Error(`Failed to create share token: ${res.status} ${text}`);
}
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(`Failed to create share token: ${res.status} ${text}`); throw new Error(`Failed to create share token: ${res.status} ${text}`);
+7 -2
View File
@@ -195,8 +195,13 @@ test.describe("Schedule with medications", () => {
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first(); const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today"); test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
await takeBtn.click(); await Promise.all([
await page.waitForLoadState("networkidle"); page.waitForResponse(
(response) => response.url().includes("/api/doses/taken") && response.request().method() === "POST",
{ timeout: 10000 }
),
takeBtn.click(),
]);
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 10000 }); await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 10000 });
}); });
+3 -1
View File
@@ -157,7 +157,9 @@ test.describe("Schedule Timeline", () => {
test("should display share button in schedules section", async ({ page }) => { test("should display share button in schedules section", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
await expect(page.locator(".taken-by-badge").first()).toBeVisible(); const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.locator(".table-row").first()).toBeVisible({ timeout: 10000 });
const shareBtn = page.locator("button.share-btn"); const shareBtn = page.locator("button.share-btn");
await expect(shareBtn).toBeVisible(); await expect(shareBtn).toBeVisible();
+71
View File
@@ -1,6 +1,9 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { authFile, navigateTo, test } from "./fixtures"; import { authFile, navigateTo, test } from "./fixtures";
const emailHeadingPattern = /Email|E-Mail/i;
const smtpUnavailablePattern = /stay unavailable until SMTP is configured|bleiben deaktiviert, bis SMTP/i;
/** /**
* Settings Page E2E Tests * Settings Page E2E Tests
* *
@@ -53,6 +56,58 @@ test.describe("Settings Page", () => {
expect(await toggles.count()).toBeGreaterThanOrEqual(2); expect(await toggles.count()).toBeGreaterThanOrEqual(2);
}); });
test("should keep email controls disabled when settings request is forbidden", async ({ page }) => {
await page.route("**/api/settings", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ error: "Forbidden", code: "FORBIDDEN" }),
});
});
await navigateTo(page, "/settings");
const emailSection = page
.locator(".setting-section")
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
.first();
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
await expect(emailToggle).toBeDisabled();
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
});
test("should keep the email toggle enabled when the settings API returns smtp configuration", async ({ page }) => {
await navigateTo(page, "/settings");
const settingsResponse = await page.evaluate(async () => {
const response = await fetch("/api/settings", { credentials: "include" });
const body = await response.json().catch(() => null);
return {
ok: response.ok,
status: response.status,
body,
};
});
test.skip(!settingsResponse.ok, `Settings request failed with status ${settingsResponse.status}`);
test.skip(!settingsResponse.body?.smtpHost, "SMTP is not configured in this environment");
const emailSection = page
.locator(".setting-section")
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
.first();
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
await expect(emailToggle).toBeEnabled();
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
});
test("should show stock settings section with threshold inputs", async ({ page }) => { test("should show stock settings section with threshold inputs", async ({ page }) => {
await navigateTo(page, "/settings"); await navigateTo(page, "/settings");
@@ -104,6 +159,22 @@ test.describe("Settings Page", () => {
await expect(exportButton).toBeVisible(); await expect(exportButton).toBeVisible();
}); });
test("should generate a new API key from the settings page", async ({ page }) => {
await navigateTo(page, "/settings");
const generateButton = page.getByRole("button", { name: /Generate key|Key erzeugen/i });
test.skip(
!(await generateButton.isVisible().catch(() => false)),
"API key action is unavailable in this environment"
);
await generateButton.click();
const tokenInput = page.locator(".api-key-token-input");
await expect(tokenInput).toBeVisible();
await expect(tokenInput).toHaveValue(/^ma_/);
});
test("should show export/import section", async ({ page }) => { test("should show export/import section", async ({ page }) => {
await navigateTo(page, "/settings"); await navigateTo(page, "/settings");
+22 -12
View File
@@ -96,6 +96,10 @@ test.describe("Share Schedule", () => {
test("should open share dialog with person list", async ({ page }) => { test("should open share dialog with person list", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
// Click the share button // Click the share button
const shareBtn = page.locator("button.share-btn"); const shareBtn = page.locator("button.share-btn");
@@ -136,7 +140,7 @@ test.describe("Share Schedule", () => {
await generateBtn.click(); await generateBtn.click();
// Wait for link to be generated // Wait for link to be generated
const shareLinkInput = modal.locator("input.share-link-input"); const shareLinkInput = modal.locator("input.share-link-input").first();
await expect(shareLinkInput).toBeVisible({ timeout: 10000 }); await expect(shareLinkInput).toBeVisible({ timeout: 10000 });
// The share link should contain /share/ // The share link should contain /share/
@@ -144,7 +148,7 @@ test.describe("Share Schedule", () => {
expect(linkValue).toContain("/share/"); expect(linkValue).toContain("/share/");
// Copy button should be visible // Copy button should be visible
await expect(modal.locator("button.btn-copy")).toBeVisible(); await expect(modal.locator("button.btn-copy").first()).toBeVisible();
// Close // Close
await page.locator("button.modal-close").click(); await page.locator("button.modal-close").click();
@@ -178,18 +182,19 @@ test.describe("Share Schedule", () => {
await page.goto(`/share/${shareToken.token}`); await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
// Wait for page content to load const sharedSchedule = page.locator(".shared-schedule-container");
await page.waitForTimeout(2000); await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
// The page should show Alice's medication name // The page should show Alice's medication name
const content = page.getByText(MED_ALICE); const content = sharedSchedule.getByText(MED_ALICE);
try { try {
await expect(content).toBeVisible({ timeout: 10000 }); await expect(content).toBeVisible({ timeout: 10000 });
} catch { } catch {
// Reload and retry — sometimes the initial load misses // Reload and retry — sometimes the initial load misses
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(content).toBeVisible({ timeout: 10000 }); await expect(content).toBeVisible({ timeout: 10000 });
} }
}); });
@@ -226,27 +231,32 @@ test.describe("Share Schedule", () => {
// Visit Alice's share — should show Alice's med // Visit Alice's share — should show Alice's med
await page.goto(`/share/${aliceToken.token}`); await page.goto(`/share/${aliceToken.token}`);
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000); await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
const sharedSchedule = page.locator(".shared-schedule-container");
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try { try {
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 }); await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
} catch { } catch {
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 }); await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
} }
// Visit Bob's share — should show Bob's med // Visit Bob's share — should show Bob's med
await page.goto(`/share/${bobToken.token}`); await page.goto(`/share/${bobToken.token}`);
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000); await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try { try {
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 }); await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
} catch { } catch {
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 }); await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
} }
}); });
+7 -4
View File
@@ -141,6 +141,7 @@ test.describe("Stock Status Levels", () => {
const overviewTable = page.locator(".dashboard-overview-section .table").first(); const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 }); await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_HIGH)).toBeVisible({ timeout: 10000 });
// High stock med row should have a .status-chip.high // High stock med row should have a .status-chip.high
const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH }); const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH });
@@ -199,15 +200,17 @@ test.describe("Stock Status Levels", () => {
await expect(overviewTable).toBeVisible({ timeout: 10000 }); await expect(overviewTable).toBeVisible({ timeout: 10000 });
// High stock should show many days (around 299) // High stock should show many days (around 299)
await expect(overviewTable.getByText(MED_HIGH)).toBeVisible({ timeout: 10000 });
const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH }); const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH });
const highRowText = await highRow.textContent(); const highRowText = (await highRow.textContent()) ?? "";
// Should contain a 3-digit number for days // Should contain a 3-digit number for days
expect(highRowText).toMatch(/\d{2,3}/); expect(highRowText).toMatch(/\d{2,3}/);
// Depleted should show 0 or very low number // Depleted rows can now show either explicit zero days left or an em dash placeholder.
await expect(overviewTable.getByText(MED_DEPLETED)).toBeVisible({ timeout: 10000 });
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED }); const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
const depletedText = await depletedRow.textContent(); const depletedText = (await depletedRow.textContent()) ?? "";
expect(depletedText).toContain("0"); expect(depletedText.includes("0") || depletedText.includes("")).toBeTruthy();
}); });
test("should show reorder reminder card with low-stock medications", async ({ page }) => { test("should show reorder reminder card with low-stock medications", async ({ page }) => {
+3
View File
@@ -26,5 +26,8 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api/, ""), rewrite: (path) => path.replace(/^\/api/, ""),
}, },
}, },
// On macOS Docker volume mounts, inotify events don't reach the
// Linux container reliably. Polling ensures HMR sees file edits.
watch: existsSync("/.dockerenv") ? { usePolling: true, interval: 300 } : undefined,
}, },
}); });