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
CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=warn
# Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
@@ -28,6 +29,10 @@ LOG_LEVEL=warn
# Increase for development/testing environments
# 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)
TZ=Europe/Berlin
+23 -4
View File
@@ -5,7 +5,10 @@
"label": "E2E stable",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e"],
"args": [
"run",
"test:e2e"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
@@ -16,7 +19,10 @@
"label": "E2E stable + merged video",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:with-video"],
"args": [
"run",
"test:e2e:with-video"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
@@ -27,7 +33,10 @@
"label": "E2E all browsers",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:all"],
"args": [
"run",
"test:e2e:all"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
@@ -38,12 +47,22 @@
"label": "E2E all browsers + merged video",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:all:with-video"],
"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
cd medassist-ng
cp .env.example .env
docker compose up -d
docker compose -p medassist-ng up -d
```
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 |
| `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. |
| `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 |
### Authentication
@@ -211,6 +212,42 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
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
| Variable | Default | Description |
@@ -309,30 +346,22 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
# Development
```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)
- 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
cd frontend
npm run test:e2e:local # local run with PLAYWRIGHT_WORKERS=4
npm run test:e2e:all:local # local all-browser run with PLAYWRIGHT_WORKERS=4
npm run lint
cd backend && npm run test:run
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
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
+4
View File
@@ -1,3 +1,5 @@
name: medassist-dev
services:
backend-dev:
image: node:22-slim
@@ -10,6 +12,7 @@ services:
env_file:
- .env
environment:
- NODE_ENV=development
- DATA_DIR=/app/data
- RATE_LIMIT_MAX=1000
ports:
@@ -33,6 +36,7 @@ services:
env_file:
- .env
environment:
- NODE_ENV=development
- BACKEND_URL=http://backend-dev:3000
ports:
- "5173:5173"
+2
View File
@@ -1,3 +1,5 @@
name: medassist-ng
services:
backend:
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 }) => {
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");
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)));
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) {
const text = await res.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();
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
await takeBtn.click();
await page.waitForLoadState("networkidle");
await Promise.all([
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 });
});
+3 -1
View File
@@ -157,7 +157,9 @@ test.describe("Schedule Timeline", () => {
test("should display share button in schedules section", async ({ page }) => {
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");
await expect(shareBtn).toBeVisible();
+71
View File
@@ -1,6 +1,9 @@
import { expect } from "@playwright/test";
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
*
@@ -53,6 +56,58 @@ test.describe("Settings Page", () => {
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 }) => {
await navigateTo(page, "/settings");
@@ -104,6 +159,22 @@ test.describe("Settings Page", () => {
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 }) => {
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 }) => {
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
const shareBtn = page.locator("button.share-btn");
@@ -136,7 +140,7 @@ test.describe("Share Schedule", () => {
await generateBtn.click();
// 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 });
// The share link should contain /share/
@@ -144,7 +148,7 @@ test.describe("Share Schedule", () => {
expect(linkValue).toContain("/share/");
// Copy button should be visible
await expect(modal.locator("button.btn-copy")).toBeVisible();
await expect(modal.locator("button.btn-copy").first()).toBeVisible();
// Close
await page.locator("button.modal-close").click();
@@ -178,18 +182,19 @@ test.describe("Share Schedule", () => {
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
// Wait for page content to load
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 });
// The page should show Alice's medication name
const content = page.getByText(MED_ALICE);
const content = sharedSchedule.getByText(MED_ALICE);
try {
await expect(content).toBeVisible({ timeout: 10000 });
} catch {
// Reload and retry — sometimes the initial load misses
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ 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
await page.goto(`/share/${aliceToken.token}`);
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 {
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
} catch {
await page.reload();
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
await page.goto(`/share/${bobToken.token}`);
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 {
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
} catch {
await page.reload();
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();
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
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 });
// 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 highRowText = await highRow.textContent();
const highRowText = (await highRow.textContent()) ?? "";
// Should contain a 3-digit number for days
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 depletedText = await depletedRow.textContent();
expect(depletedText).toContain("0");
const depletedText = (await depletedRow.textContent()) ?? "";
expect(depletedText.includes("0") || depletedText.includes("")).toBeTruthy();
});
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/, ""),
},
},
// 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,
},
});