From 733fe2f38ac3c5b5769f77b106c30bb922672925 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Tue, 10 Mar 2026 06:25:46 +0100 Subject: [PATCH] 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 --- .env.example | 5 ++ .vscode/tasks.json | 115 ++++++++++++++++------------ README.md | 61 +++++++++++---- docker-compose.dev.yml | 4 + docker-compose.yml | 2 + frontend/e2e/dashboard-data.spec.ts | 3 + frontend/e2e/fixtures/index.ts | 9 +++ frontend/e2e/schedule-data.spec.ts | 9 ++- frontend/e2e/schedule.spec.ts | 4 +- frontend/e2e/settings.spec.ts | 71 +++++++++++++++++ frontend/e2e/share-schedule.spec.ts | 34 +++++--- frontend/e2e/stock-status.spec.ts | 11 ++- frontend/vite.config.ts | 3 + 13 files changed, 248 insertions(+), 83 deletions(-) diff --git a/.env.example b/.env.example index c1a0592..cf43c72 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 53a04a8..00d5ad9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,49 +1,68 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "E2E stable", - "type": "shell", - "command": "npm", - "args": ["run", "test:e2e"], - "options": { - "cwd": "${workspaceFolder}/frontend" - }, - "group": "test", - "problemMatcher": [] - }, - { - "label": "E2E stable + merged video", - "type": "shell", - "command": "npm", - "args": ["run", "test:e2e:with-video"], - "options": { - "cwd": "${workspaceFolder}/frontend" - }, - "group": "test", - "problemMatcher": [] - }, - { - "label": "E2E all browsers", - "type": "shell", - "command": "npm", - "args": ["run", "test:e2e:all"], - "options": { - "cwd": "${workspaceFolder}/frontend" - }, - "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": [] - } - ] -} + "version": "2.0.0", + "tasks": [ + { + "label": "E2E stable", + "type": "shell", + "command": "npm", + "args": [ + "run", + "test:e2e" + ], + "options": { + "cwd": "${workspaceFolder}/frontend" + }, + "group": "test", + "problemMatcher": [] + }, + { + "label": "E2E stable + merged video", + "type": "shell", + "command": "npm", + "args": [ + "run", + "test:e2e:with-video" + ], + "options": { + "cwd": "${workspaceFolder}/frontend" + }, + "group": "test", + "problemMatcher": [] + }, + { + "label": "E2E all browsers", + "type": "shell", + "command": "npm", + "args": [ + "run", + "test:e2e:all" + ], + "options": { + "cwd": "${workspaceFolder}/frontend" + }, + "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" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 2bc70a9..21084dc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 84ae11a..e678fdc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 64de583..bd5675f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +name: medassist-ng + services: backend: image: ghcr.io/danielvolz/medassist-ng-backend:latest diff --git a/frontend/e2e/dashboard-data.spec.ts b/frontend/e2e/dashboard-data.spec.ts index 38c4c6a..de6a999 100644 --- a/frontend/e2e/dashboard-data.spec.ts +++ b/frontend/e2e/dashboard-data.spec.ts @@ -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 }); diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts index 6c3e5bb..3870a14 100644 --- a/frontend/e2e/fixtures/index.ts +++ b/frontend/e2e/fixtures/index.ts @@ -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}`); diff --git a/frontend/e2e/schedule-data.spec.ts b/frontend/e2e/schedule-data.spec.ts index cb7533d..7b80403 100644 --- a/frontend/e2e/schedule-data.spec.ts +++ b/frontend/e2e/schedule-data.spec.ts @@ -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 }); }); diff --git a/frontend/e2e/schedule.spec.ts b/frontend/e2e/schedule.spec.ts index 4e3ff9f..5c95338 100644 --- a/frontend/e2e/schedule.spec.ts +++ b/frontend/e2e/schedule.spec.ts @@ -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(); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 3cfec91..e9eb45e 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -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"); diff --git a/frontend/e2e/share-schedule.spec.ts b/frontend/e2e/share-schedule.spec.ts index 9bb3b48..0e04505 100644 --- a/frontend/e2e/share-schedule.spec.ts +++ b/frontend/e2e/share-schedule.spec.ts @@ -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 }); } }); diff --git a/frontend/e2e/stock-status.spec.ts b/frontend/e2e/stock-status.spec.ts index a10680a..bbd1f73 100644 --- a/frontend/e2e/stock-status.spec.ts +++ b/frontend/e2e/stock-status.spec.ts @@ -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 }) => { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e5372d0..78752a8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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, }, });