diff --git a/.github/agents/testing-manager.agent.md b/.github/agents/testing-manager.agent.md index 5731e91..e086c4c 100644 --- a/.github/agents/testing-manager.agent.md +++ b/.github/agents/testing-manager.agent.md @@ -15,6 +15,7 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe - **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. - **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior. - **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs. +- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters. - **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready. - **Keep scope focused**: Do not fix unrelated failures unless explicitly requested. @@ -67,8 +68,8 @@ cd frontend && npm run build ```bash cd frontend && npm run test:e2e cd frontend && npm run test:e2e -- --project=chromium -cd frontend && npm run test:e2e:ui -cd frontend && npm run test:e2e:headed +# Never use interactive UI/headed/report-server commands in agent runs. +# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report ``` ## Backend Test Patterns diff --git a/.vscode/settings.json b/.vscode/settings.json index bf62a38..9726229 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "vitest.root": "backend", "vitest.enable": true, - "vitest.commandLine": "npm test --" + "vitest.commandLine": "npm test --", + "chat.tools.terminal.autoApprove": { + "test": true + } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..53a04a8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,49 @@ +{ + "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": [] + } + ] +} diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index df1b43e..e3da5d5 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -140,6 +140,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`, // Added for share stock visibility toggle `ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`, + // Added for timeline visibility toggles (dashboard + shared schedule) + `ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`, + `ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`, + `ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`, // Added for prescription refill tracking and reminders `ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`, `ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`, diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts index 96d9690..963927b 100644 --- a/backend/src/db/schema-sql.ts +++ b/backend/src/db/schema-sql.ts @@ -65,9 +65,21 @@ export function getTableCreationSQL(): string[] { expiry_warning_days integer NOT NULL DEFAULT 90, language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', + share_stock_status integer NOT NULL DEFAULT 1, + upcoming_today_only integer NOT NULL DEFAULT 0, + share_schedule_today_only integer NOT NULL DEFAULT 0, + swap_dashboard_main_sections integer NOT NULL DEFAULT 0, last_auto_email_sent text, last_notification_type text, last_notification_channel text, + last_reminder_med_name text, + last_reminder_taken_by text, + last_stock_reminder_sent text, + last_stock_reminder_channel text, + last_stock_reminder_med_names text, + last_prescription_reminder_sent text, + last_prescription_reminder_channel text, + last_prescription_reminder_med_names text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 4332019..84db3a3 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -100,6 +100,10 @@ export const userSettings = sqliteTable("user_settings", { stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), // Whether shared schedule links show stock status (Critical/Low/Normal) to intake users shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true), + // UI timeline visibility preferences + upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false), + shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false), + swapDashboardMainSections: integer("swap_dashboard_main_sections", { mode: "boolean" }).notNull().default(false), // Last notification tracking (intake reminders) lastAutoEmailSent: text("last_auto_email_sent"), lastNotificationType: text("last_notification_type"), diff --git a/backend/src/index.ts b/backend/src/index.ts index 15c2af9..5dcd91a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,7 @@ import { medicationRoutes } from "./routes/medications.js"; import { oidcRoutes } from "./routes/oidc.js"; import { plannerRoutes } from "./routes/planner.js"; import { refillRoutes } from "./routes/refills.js"; +import { reportRoutes } from "./routes/report.js"; import { settingsRoutes } from "./routes/settings.js"; import { shareRoutes } from "./routes/share.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; @@ -118,6 +119,7 @@ export async function createApp(options?: { await app.register(doseRoutes); await app.register(exportRoutes); await app.register(refillRoutes); + await app.register(reportRoutes); return app; } @@ -190,6 +192,7 @@ await app.register(shareRoutes); await app.register(doseRoutes); await app.register(exportRoutes); await app.register(refillRoutes); +await app.register(reportRoutes); const start = async () => { try { diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 06eb9ae..6da4ac8 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -623,9 +623,9 @@ export async function medicationRoutes(app: FastifyInstance) { }; }); - // Stock correction endpoint - only updates stockAdjustment, preserves looseTablets + // Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type) // Also sets lastStockCorrectionAt so consumed doses before this point don't count - app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>( + app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>( "/medications/:id/stock-adjustment", async (req, reply) => { const idNum = Number(req.params.id); @@ -640,16 +640,32 @@ export async function medicationRoutes(app: FastifyInstance) { .where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); - const { stockAdjustment } = req.body as { stockAdjustment: number }; + const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number }; if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number"); + if ( + looseTablets !== undefined && + (typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0) + ) { + return reply.badRequest("looseTablets must be a non-negative integer"); + } + + const updateFields: { + stockAdjustment: number; + lastStockCorrectionAt: Date; + updatedAt: Date; + looseTablets?: number; + } = { + stockAdjustment, + lastStockCorrectionAt: new Date(), + updatedAt: new Date(), + }; + if (looseTablets !== undefined) { + updateFields.looseTablets = looseTablets; + } const result = await db .update(medications) - .set({ - stockAdjustment, - lastStockCorrectionAt: new Date(), // Mark when correction was made - updatedAt: new Date(), - }) + .set(updateFields) .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .returning(); diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index 815be3b..a6630f4 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -52,23 +52,34 @@ export async function refillRoutes(app: FastifyInstance) { if (!med) return reply.notFound("Medication not found"); const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; + const isBottle = (med.packageType ?? "blister") === "bottle"; + const effectivePacksAdded = isBottle ? 0 : packsAdded; + const effectiveLoosePillsAdded = loosePillsAdded; + const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; + + if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) { + return reply.status(400).send({ error: "Must add at least one pack or some loose pills" }); + } if (usePrescription) { if (!(med.prescriptionEnabled ?? false)) { return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" }); } - const remaining = med.prescriptionRemainingRefills ?? 0; - if (remaining <= 0) { + if (remainingPrescriptionRefills <= 0) { return reply.status(409).send({ error: "No remaining prescription refills" }); } + if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) { + return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" }); + } } // Update medication stock - const newPackCount = med.packCount + packsAdded; - const newLooseTablets = med.looseTablets + loosePillsAdded; + const newPackCount = med.packCount + effectivePacksAdded; + const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; + const consumedRefills = usePrescription ? (isBottle ? 1 : effectivePacksAdded) : 0; const newRemainingRefills = usePrescription - ? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1) + ? Math.max(0, remainingPrescriptionRefills - consumedRefills) : (med.prescriptionRemainingRefills ?? null); await db @@ -77,8 +88,6 @@ export async function refillRoutes(app: FastifyInstance) { packCount: newPackCount, looseTablets: newLooseTablets, prescriptionRemainingRefills: newRemainingRefills, - stockAdjustment: 0, // Reset offset since we're adding to base stock - lastStockCorrectionAt: new Date(), // Reset consumed counter to now updatedAt: new Date(), }) .where(and(eq(medications.id, medId), eq(medications.userId, userId))); @@ -89,16 +98,17 @@ export async function refillRoutes(app: FastifyInstance) { .values({ medicationId: medId, userId, - packsAdded, - loosePillsAdded, + packsAdded: effectivePacksAdded, + loosePillsAdded: effectiveLoosePillsAdded, usedPrescription: usePrescription, }) .returning(); // Calculate pills added for response (packageType-aware) - const isBottle = (med.packageType ?? "blister") === "bottle"; const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; - const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded; + const totalPillsAdded = isBottle + ? effectiveLoosePillsAdded + : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded; const newTotalPills = isBottle ? newLooseTablets + (med.stockAdjustment ?? 0) : newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0); @@ -107,8 +117,8 @@ export async function refillRoutes(app: FastifyInstance) { success: true, refill: { id: refill.id, - packsAdded, - loosePillsAdded, + packsAdded: effectivePacksAdded, + loosePillsAdded: effectiveLoosePillsAdded, totalPillsAdded, refillDate: refill.refillDate, }, diff --git a/backend/src/routes/report.ts b/backend/src/routes/report.ts new file mode 100644 index 0000000..72f6adb --- /dev/null +++ b/backend/src/routes/report.ts @@ -0,0 +1,105 @@ +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { doseTracking, medications, refillHistory } from "../db/schema.js"; +import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; +import { env } from "../plugins/env.js"; +import type { AuthUser } from "../types/fastify.js"; + +const reportDataSchema = z.object({ + medicationIds: z.array(z.number().int().positive()).min(1).max(100), +}); + +export async function reportRoutes(app: FastifyInstance) { + app.addHook("preHandler", requireAuth); + + async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { + if (!env.AUTH_ENABLED) { + return getAnonymousUserId(); + } + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" }); + throw new Error("AUTH_REQUIRED"); + } + return authUser.id; + } + + // POST /medications/report-data - Get aggregated dose/refill data for report generation + app.post("/medications/report-data", async (req, reply) => { + const parsed = reportDataSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + + const userId = await getUserId(req, reply); + const { medicationIds } = parsed.data; + + // Verify all medications belong to this user + const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)); + const userMedIds = new Set(userMeds.map((m) => m.id)); + + for (const id of medicationIds) { + if (!userMedIds.has(id)) { + return reply.status(403).send({ error: "Access denied to medication" }); + } + } + + // Fetch dose tracking for all requested medications + // doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}" + const allDoses = await db + .select({ + doseId: doseTracking.doseId, + takenAt: doseTracking.takenAt, + dismissed: doseTracking.dismissed, + }) + .from(doseTracking) + .where(eq(doseTracking.userId, userId)); + + // Group doses by medication ID + const dosesByMed = new Map(); + for (const dose of allDoses) { + const medId = Number.parseInt(dose.doseId.split("-")[0], 10); + if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue; + if (!dosesByMed.has(medId)) dosesByMed.set(medId, []); + dosesByMed.get(medId)!.push({ takenAt: dose.takenAt, dismissed: dose.dismissed }); + } + + // Fetch refill history for requested medications + const result: Record< + number, + { + dosesTaken: number; + dosesDismissed: number; + firstDoseAt: string | null; + lastDoseAt: string | null; + refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; + } + > = {}; + + for (const medId of medicationIds) { + const doses = dosesByMed.get(medId) ?? []; + const takenDoses = doses.filter((d) => !d.dismissed); + const dismissedDoses = doses.filter((d) => d.dismissed); + + const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b); + + // Get refills for this medication + const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId)); + + result[medId] = { + dosesTaken: takenDoses.length, + dosesDismissed: dismissedDoses.length, + firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null, + lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null, + refills: refills.map((r) => ({ + packsAdded: r.packsAdded, + loosePillsAdded: r.loosePillsAdded, + usedPrescription: r.usedPrescription ?? false, + refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate), + })), + }; + } + + return result; + }); +} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 80ea2d4..13e19ec 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -33,6 +33,9 @@ export type UserSettings = { language: Language; stockCalculationMode: "automatic" | "manual"; shareStockStatus: boolean; + upcomingTodayOnly: boolean; + shareScheduleTodayOnly: boolean; + swapDashboardMainSections: boolean; lastAutoEmailSent: string | null; lastNotificationType: string | null; lastNotificationChannel: string | null; @@ -69,6 +72,9 @@ type SettingsBody = { language: string; stockCalculationMode: "automatic" | "manual"; shareStockStatus: boolean; + upcomingTodayOnly: boolean; + shareScheduleTodayOnly: boolean; + swapDashboardMainSections: boolean; }; type TestEmailBody = { @@ -119,6 +125,9 @@ function getDefaultSettings() { language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en", stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic", shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true), + upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false), + shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false), + swapDashboardMainSections: false, lastAutoEmailSent: null, lastNotificationType: null, lastNotificationChannel: null, @@ -178,6 +187,9 @@ export async function loadUserSettings(userId: number): Promise { language: settings.language as Language, stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", shareStockStatus: settings.shareStockStatus ?? true, + upcomingTodayOnly: settings.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: settings.swapDashboardMainSections ?? false, lastAutoEmailSent: settings.lastAutoEmailSent, lastNotificationType: settings.lastNotificationType, lastNotificationChannel: settings.lastNotificationChannel, @@ -219,6 +231,9 @@ export async function getAllUserSettings(): Promise { language: settings.language as Language, stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", shareStockStatus: settings.shareStockStatus ?? true, + upcomingTodayOnly: settings.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: settings.swapDashboardMainSections ?? false, lastAutoEmailSent: settings.lastAutoEmailSent, lastNotificationType: settings.lastNotificationType, lastNotificationChannel: settings.lastNotificationChannel, @@ -283,6 +298,9 @@ export async function settingsRoutes(app: FastifyInstance) { language: settings.language, stockCalculationMode: settings.stockCalculationMode ?? "automatic", shareStockStatus: settings.shareStockStatus ?? true, + upcomingTodayOnly: settings.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: settings.swapDashboardMainSections ?? false, // SMTP settings (from .env - shared/server-configured) smtpHost: process.env.SMTP_HOST ?? "", smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10), @@ -349,6 +367,9 @@ export async function settingsRoutes(app: FastifyInstance) { language: body.language ?? "en", stockCalculationMode: body.stockCalculationMode ?? "automatic", shareStockStatus: body.shareStockStatus ?? true, + upcomingTodayOnly: body.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false, + swapDashboardMainSections: body.swapDashboardMainSections ?? false, updatedAt: new Date(), }; diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 954fd3b..09f69ec 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -154,6 +154,8 @@ export async function shareRoutes(app: FastifyInstance) { }, stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", shareStockStatus: settings?.shareStockStatus ?? true, + upcomingTodayOnly: settings?.upcomingTodayOnly ?? false, + shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false, }; }); diff --git a/backend/src/test/db-client.test.ts b/backend/src/test/db-client.test.ts new file mode 100644 index 0000000..30fe997 --- /dev/null +++ b/backend/src/test/db-client.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +type ClientTestOptions = { + dirWritable?: boolean; + authEnabled?: boolean; +}; + +async function loadDbClientModule(options: ClientTestOptions = {}) { + const { dirWritable = true, authEnabled = false } = options; + + vi.resetModules(); + vi.restoreAllMocks(); + + process.env.AUTH_ENABLED = authEnabled ? "true" : "false"; + process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env"; + + const existsSync = vi.fn().mockReturnValue(false); + const statSync = vi.fn().mockReturnValue({ mode: 0o40755, uid: 1000, gid: 1000 }); + vi.doMock("node:fs", () => ({ existsSync, statSync })); + + const dotenvConfig = vi.fn(); + vi.doMock("dotenv", () => ({ default: { config: dotenvConfig } })); + + const createClient = vi.fn().mockReturnValue({ execute: vi.fn() }); + vi.doMock("@libsql/client", () => ({ createClient })); + + const drizzle = vi.fn().mockReturnValue({ __db: true }); + vi.doMock("drizzle-orm/libsql", () => ({ drizzle })); + + const ensureDataDirectory = vi + .fn() + .mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" }); + const getDbPaths = vi.fn().mockReturnValue({ + dataDir: "/tmp/medassist-data", + dbPath: "/tmp/medassist-data/medassist.db", + url: "file:/tmp/medassist-data/medassist.db", + }); + const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true }); + const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] }); + const repairTrailingHyphenDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] }); + const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] }); + const ensureDefaultUser = vi.fn().mockResolvedValue(false); + + vi.doMock("../db/db-utils.js", () => ({ + buildDbUrl: vi.fn(), + getDataDir: vi.fn(), + ensureDataDirectory, + getDbPaths, + runDrizzleMigrations, + runAlterMigrations, + repairTrailingHyphenDoseIds, + repairOrphanedDoseIds, + ensureDefaultUser, + })); + + const log = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + vi.doMock("../utils/logger.js", () => ({ log })); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + const modulePromise = import("../db/client.js"); + + return { + modulePromise, + mocks: { + existsSync, + statSync, + dotenvConfig, + createClient, + drizzle, + ensureDataDirectory, + getDbPaths, + runDrizzleMigrations, + runAlterMigrations, + repairTrailingHyphenDoseIds, + repairOrphanedDoseIds, + ensureDefaultUser, + log, + exitSpy, + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("db/client bootstrap", () => { + it("initializes db and runs migrations when directory is writable", async () => { + const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: false }); + const mod = await modulePromise; + + expect(mod.db).toBeTruthy(); + expect(mod.migrationsReady).toBeInstanceOf(Promise); + await mod.migrationsReady; + + expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data"); + expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" }); + expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1); + expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1); + expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1); + expect(mocks.repairOrphanedDoseIds).toHaveBeenCalledTimes(1); + expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), false); + }); + + it("passes auth-enabled flag to ensureDefaultUser", async () => { + const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: true }); + const mod = await modulePromise; + await mod.migrationsReady; + + expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), true); + }); + + it("exits when data directory is not writable", async () => { + const { modulePromise } = await loadDbClientModule({ dirWritable: false }); + await expect(modulePromise).rejects.toThrow("process.exit:1"); + }); +}); diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 8e6c3b7..2498186 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -55,6 +55,7 @@ const { medicationRoutes } = await import("../routes/medications.js"); const { settingsRoutes } = await import("../routes/settings.js"); const { healthRoutes } = await import("../routes/health.js"); const { refillRoutes } = await import("../routes/refills.js"); +const { reportRoutes } = await import("../routes/report.js"); const { exportRoutes } = await import("../routes/export.js"); // ============================================================================= @@ -137,6 +138,9 @@ async function createSchema(client: Client) { language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', share_stock_status integer NOT NULL DEFAULT 1, + upcoming_today_only integer NOT NULL DEFAULT 0, + share_schedule_today_only integer NOT NULL DEFAULT 0, + swap_dashboard_main_sections integer NOT NULL DEFAULT 0, last_auto_email_sent text, last_notification_type text, last_notification_channel text, @@ -261,11 +265,80 @@ describe("E2E Tests with Real Routes", () => { await app.register(settingsRoutes); await app.register(healthRoutes); await app.register(refillRoutes); + await app.register(reportRoutes); await app.register(exportRoutes); await app.ready(); }); + // --------------------------------------------------------------------------- + // Report Routes + // --------------------------------------------------------------------------- + + describe("Real /medications/report-data route", () => { + it("should return 400 for invalid payload", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return 403 when requested medication is not owned by user", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [999999] }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error).toBe("Access denied to medication"); + }); + + it("should aggregate taken/dismissed doses and refill history", async () => { + const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]); + + // One taken dose and one dismissed dose for the same medication + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) + VALUES (?, ?, ?, 0)`, + args: [userId, `${medId}-0-1735344000000`, 1735344000], + }); + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) + VALUES (?, ?, ?, 1)`, + args: [userId, `${medId}-0-1735430400000-Daniel`, 1735430400], + }); + + await testClient.execute({ + sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [medId, userId, 2, 5, 1, 1735516800], + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [medId] }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data[medId].dosesTaken).toBe(1); + expect(data[medId].dosesDismissed).toBe(1); + expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString()); + expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString()); + expect(data[medId].refills).toHaveLength(1); + expect(data[medId].refills[0]).toMatchObject({ + packsAdded: 2, + loosePillsAdded: 5, + usedPrescription: true, + }); + }); + }); + afterAll(async () => { await app.close(); testClient.close(); @@ -744,6 +817,39 @@ describe("E2E Tests with Real Routes", () => { const data = getResponse.json(); expect(data.repeatDailyReminders).toBe(false); }); + + it("should reject invalid language in lightweight language endpoint", async () => { + const response = await app.inject({ + method: "PUT", + url: "/settings/language", + payload: { language: "fr" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Invalid language"); + }); + + it("should create and update language via lightweight language endpoint", async () => { + let response = await app.inject({ + method: "PUT", + url: "/settings/language", + payload: { language: "de" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + response = await app.inject({ + method: "PUT", + url: "/settings/language", + payload: { language: "en" }, + }); + + expect(response.statusCode).toBe(200); + + const getResponse = await app.inject({ method: "GET", url: "/settings" }); + expect(getResponse.json().language).toBe("en"); + }); }); // --------------------------------------------------------------------------- @@ -2203,6 +2309,87 @@ describe("E2E Tests with Real Routes", () => { expect(data.settings).toBeDefined(); expect(data.settings.emailEnabled).toBe(true); }); + + it("should include sensitive settings when requested", async () => { + await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: true, + shoutrrrUrl: "https://example.com/topic", + emailStockReminders: false, + emailIntakeReminders: false, + emailPrescriptionReminders: false, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + shoutrrrPrescriptionReminders: true, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + language: "en", + stockCalculationMode: "automatic", + shareStockStatus: true, + upcomingTodayOnly: false, + shareScheduleTodayOnly: false, + swapDashboardMainSections: false, + }, + }); + + const response = await app.inject({ + method: "GET", + url: "/export?includeSensitive=true", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.settings.shoutrrrEnabled).toBe(true); + expect(data.settings.shoutrrrUrl).toBe("https://example.com/topic"); + }); + + it("should gracefully export malformed date-like DB values", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Date Edge Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id as number; + + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)`, + args: [userId, `${medId}-0-1735344000000`, "not-a-date"], + }); + await testClient.execute({ + sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [medId, userId, 1, 0, 0, "still-not-a-date"], + }); + await testClient.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`, + args: [userId, "date-edge-token", "Daniel", 30, "broken-date"], + }); + + const response = await app.inject({ method: "GET", url: "/export" }); + expect(response.statusCode).toBe(200); + + const data = response.json(); + expect(data.doseHistory).toHaveLength(1); + expect(Number.isNaN(Date.parse(data.doseHistory[0].takenAt))).toBe(false); + expect(data.refillHistory).toHaveLength(1); + expect(Number.isNaN(Date.parse(data.refillHistory[0].refillDate))).toBe(false); + expect(data.shareLinks).toHaveLength(1); + expect(data.shareLinks[0].expiresAt).toBeNull(); + }); }); describe("Real /import routes", () => { diff --git a/backend/src/test/env-runtime.test.ts b/backend/src/test/env-runtime.test.ts new file mode 100644 index 0000000..5067603 --- /dev/null +++ b/backend/src/test/env-runtime.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const ORIGINAL_ENV = { ...process.env }; + +describe("plugins/env runtime validation", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { + ...ORIGINAL_ENV, + DOTENV_PATH: "/tmp/medassist-nonexistent.env", + }; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it("loads with defaults when auth and oidc are disabled", async () => { + delete process.env.AUTH_ENABLED; + delete process.env.OIDC_ENABLED; + delete process.env.JWT_SECRET; + delete process.env.REFRESH_SECRET; + delete process.env.COOKIE_SECRET; + + const mod = await import("../plugins/env.js"); + expect(mod.env.AUTH_ENABLED).toBe(false); + expect(mod.env.OIDC_ENABLED).toBe(false); + expect(mod.env.PORT).toBe(3000); + }); + + it("exits when auth is enabled but secrets are missing", async () => { + process.env.AUTH_ENABLED = "true"; + delete process.env.JWT_SECRET; + delete process.env.REFRESH_SECRET; + delete process.env.COOKIE_SECRET; + + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1"); + }); + + it("exits when oidc is enabled but required settings are missing", async () => { + process.env.AUTH_ENABLED = "false"; + process.env.OIDC_ENABLED = "true"; + delete process.env.OIDC_ISSUER_URL; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + delete process.env.OIDC_REDIRECT_URI; + + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1"); + }); + + it("loads when auth and oidc settings are complete", async () => { + process.env.AUTH_ENABLED = "true"; + process.env.JWT_SECRET = "jwt-secret-for-runtime-test"; + process.env.REFRESH_SECRET = "refresh-secret-runtime-test"; + process.env.COOKIE_SECRET = "cookie-secret-runtime-test"; + process.env.OIDC_ENABLED = "true"; + process.env.OIDC_ISSUER_URL = "https://auth.example.com"; + process.env.OIDC_CLIENT_ID = "medassist"; + process.env.OIDC_CLIENT_SECRET = "super-secret-client"; + process.env.OIDC_REDIRECT_URI = "https://app.example.com/api/auth/oidc/callback"; + + const mod = await import("../plugins/env.js"); + expect(mod.env.AUTH_ENABLED).toBe(true); + expect(mod.env.OIDC_ENABLED).toBe(true); + expect(mod.env.OIDC_CLIENT_ID).toBe("medassist"); + }); +}); diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 0611dd1..f15ef7b 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -132,6 +132,9 @@ async function createSchema(client: Client) { language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', share_stock_status integer NOT NULL DEFAULT 1, + upcoming_today_only integer NOT NULL DEFAULT 0, + share_schedule_today_only integer NOT NULL DEFAULT 0, + swap_dashboard_main_sections integer NOT NULL DEFAULT 0, last_auto_email_sent text, last_notification_type text, last_notification_channel text, diff --git a/backend/src/test/oidc.test.ts b/backend/src/test/oidc.test.ts new file mode 100644 index 0000000..64a2eed --- /dev/null +++ b/backend/src/test/oidc.test.ts @@ -0,0 +1,151 @@ +import cookie from "@fastify/cookie"; +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +type OidcMocks = { + discovery: ReturnType; + buildAuthorizationUrl: ReturnType; +}; + +async function buildOidcApp(envOverrides: Record) { + vi.resetModules(); + + const env = { + OIDC_ENABLED: true, + OIDC_ISSUER_URL: "https://issuer.example.com", + OIDC_CLIENT_ID: "medassist-client", + OIDC_CLIENT_SECRET: "medassist-client-secret", + OIDC_REDIRECT_URI: "https://app.example.com/api/auth/oidc/callback", + OIDC_SCOPES: "openid profile email", + OIDC_AUTO_CREATE_USERS: true, + OIDC_USERNAME_CLAIM: "preferred_username", + OIDC_PROVIDER_NAME: "SSO", + NODE_ENV: "test", + CORS_ORIGINS: "http://localhost:5173", + ACCESS_TOKEN_TTL_MINUTES: 15, + REFRESH_TOKEN_TTL_DAYS: 7, + ...envOverrides, + }; + + vi.doMock("../plugins/env.js", () => ({ env })); + + vi.doMock("../db/client.js", () => ({ + db: { + select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 1, username: "sso-user" }]) })), + })), + update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) })), + }, + })); + + const discovery = vi.fn().mockResolvedValue({ issuer: "https://issuer.example.com" }); + const buildAuthorizationUrl = vi.fn().mockImplementation((_cfg, params) => { + const state = typeof params?.state === "string" ? params.state : "state"; + return new URL(`https://issuer.example.com/authorize?state=${state}`); + }); + + vi.doMock("openid-client", () => ({ + discovery, + buildAuthorizationUrl, + authorizationCodeGrant: vi.fn(), + fetchUserInfo: vi.fn(), + })); + + const { oidcRoutes } = await import("../routes/oidc.js"); + + const app = Fastify({ logger: false }); + await app.register(cookie, { secret: "test-cookie-secret" }); + app.decorate("config", { + accessSecret: "test-jwt-secret-12345", + refreshSecret: "test-refresh-secret-12345", + accessTtl: 15 * 60, + refreshTtl: 7 * 24 * 60 * 60, + cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, + refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth" }, + }); + await app.register(oidcRoutes); + await app.ready(); + + return { + app, + mocks: { discovery, buildAuthorizationUrl } as OidcMocks, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("OIDC routes", () => { + it("returns 400 on login and callback when oidc is disabled", async () => { + const { app } = await buildOidcApp({ OIDC_ENABLED: false }); + try { + const login = await app.inject({ method: "GET", url: "/auth/oidc/login" }); + const callback = await app.inject({ method: "GET", url: "/auth/oidc/callback" }); + + expect(login.statusCode).toBe(400); + expect(callback.statusCode).toBe(400); + } finally { + await app.close(); + } + }); + + it("redirects to provider and sets PKCE cookies on /auth/oidc/login", async () => { + const { app, mocks } = await buildOidcApp({ OIDC_ENABLED: true }); + try { + const res = await app.inject({ method: "GET", url: "/auth/oidc/login" }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toContain("https://issuer.example.com/authorize"); + expect(res.cookies.some((c) => c.name === "oidc_code_verifier")).toBe(true); + expect(res.cookies.some((c) => c.name === "oidc_state")).toBe(true); + expect(mocks.discovery).toHaveBeenCalledTimes(1); + expect(mocks.buildAuthorizationUrl).toHaveBeenCalledTimes(1); + } finally { + await app.close(); + } + }); + + it("redirects with provider error when callback contains error params", async () => { + const { app } = await buildOidcApp({ OIDC_ENABLED: true }); + try { + const res = await app.inject({ + method: "GET", + url: "/auth/oidc/callback?error=access_denied&error_description=user_cancelled", + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied"); + } finally { + await app.close(); + } + }); + + it("redirects when callback is missing required params", async () => { + const { app } = await buildOidcApp({ OIDC_ENABLED: true }); + try { + const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params"); + } finally { + await app.close(); + } + }); + + it("redirects when callback state validation fails", async () => { + const { app } = await buildOidcApp({ OIDC_ENABLED: true }); + try { + const res = await app.inject({ + method: "GET", + url: "/auth/oidc/callback?code=abc123&state=state123", + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch"); + } finally { + await app.close(); + } + }); +}); diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index 028a285..e2de0cc 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -149,6 +149,9 @@ async function createSchema(client: Client) { language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', share_stock_status integer NOT NULL DEFAULT 1, + upcoming_today_only integer NOT NULL DEFAULT 0, + share_schedule_today_only integer NOT NULL DEFAULT 0, + swap_dashboard_main_sections integer NOT NULL DEFAULT 0, last_auto_email_sent text, last_notification_type text, last_notification_channel text, diff --git a/backend/src/test/routes-real.test.ts b/backend/src/test/routes-real.test.ts new file mode 100644 index 0000000..6163f17 --- /dev/null +++ b/backend/src/test/routes-real.test.ts @@ -0,0 +1,422 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import Fastify, { type FastifyInstance } from "fastify"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runAlterMigrations } from "../db/db-utils.js"; + +const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => { + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + const env = { + AUTH_ENABLED: false, + OIDC_ENABLED: false, + OIDC_PROVIDER_NAME: "SSO", + NODE_ENV: "test", + }; + return { + testClient: client, + testDb: db, + mockedEnv: env, + nodemailerSendMail: vi.fn(), + fetchMock: vi.fn(), + }; +}); + +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); + +vi.mock("../plugins/auth.js", () => ({ + requireAuth: async () => {}, + getAnonymousUserId: async () => 1, +})); + +vi.mock("nodemailer", () => ({ + default: { + createTransport: () => ({ + sendMail: nodemailerSendMail, + }), + }, +})); + +const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js"); +const { exportRoutes } = await import("../routes/export.js"); +const { reportRoutes } = await import("../routes/report.js"); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + +async function clearTables() { + await testClient.execute("DELETE FROM refill_history"); + await testClient.execute("DELETE FROM dose_tracking"); + await testClient.execute("DELETE FROM share_tokens"); + await testClient.execute("DELETE FROM user_settings"); + await testClient.execute("DELETE FROM medications"); + await testClient.execute("DELETE FROM users"); +} + +async function seedAnonymousUser() { + await testClient.execute({ + sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)", + args: [1, "anon", "anonymous"], + }); +} + +async function seedMedication(name = "Aspirin") { + const result = await testClient.execute({ + sql: `INSERT INTO medications ( + user_id, name, generic_name, taken_by_json, package_type, + pack_count, blisters_per_pack, pills_per_blister, loose_tablets, + usage_json, every_json, start_json, intakes_json, + stock_adjustment, intake_reminders_enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + args: [ + 1, + name, + "Acetylsalicylic acid", + JSON.stringify(["Daniel"]), + "blister", + 2, + 2, + 10, + 3, + JSON.stringify([1]), + JSON.stringify([1]), + JSON.stringify(["2026-01-01T08:00:00.000Z"]), + JSON.stringify([ + { usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", takenBy: "Daniel", intakeRemindersEnabled: true }, + ]), + 0, + 1, + ], + }); + return result.rows[0].id as number; +} + +describe("Real route coverage: settings/export/report", () => { + let app: FastifyInstance; + + beforeAll(async () => { + await migrate(testDb, { migrationsFolder }); + await runAlterMigrations(testClient); + app = Fastify({ logger: false }); + await app.register(settingsRoutes); + await app.register(exportRoutes); + await app.register(reportRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + await clearTables(); + await seedAnonymousUser(); + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_TOKEN; + delete process.env.SMTP_PASS; + delete process.env.SMTP_FROM; + delete process.env.SMTP_PORT; + delete process.env.SMTP_SECURE; + }); + + it("GET /settings creates defaults for anonymous user", async () => { + const response = await app.inject({ method: "GET", url: "/settings" }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.language).toBe("en"); + expect(body.shareStockStatus).toBe(true); + expect(body.upcomingTodayOnly).toBe(false); + expect(body.shareScheduleTodayOnly).toBe(false); + }); + + it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => { + const response = await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: true, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + emailPrescriptionReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + shoutrrrPrescriptionReminders: true, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + language: "en", + stockCalculationMode: "automatic", + shareStockStatus: true, + upcomingTodayOnly: false, + shareScheduleTodayOnly: false, + swapDashboardMainSections: false, + }, + }); + + expect(response.statusCode).toBe(200); + + const stored = await testClient.execute({ + sql: "SELECT repeat_daily_reminders FROM user_settings WHERE user_id = 1", + }); + expect(stored.rows[0].repeat_daily_reminders).toBe(0); + }); + + it("PUT /settings/language validates supported language", async () => { + const response = await app.inject({ + method: "PUT", + url: "/settings/language", + payload: { language: "fr" }, + }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Invalid language"); + }); + + it("POST /settings/test-email fails when SMTP is not configured", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-email", + payload: { email: "person@example.com" }, + }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("SMTP not configured"); + }); + + it("POST /settings/test-email sends email when SMTP is configured", async () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_USER = "mailer@example.com"; + process.env.SMTP_TOKEN = "secret"; + nodemailerSendMail.mockResolvedValue(undefined); + + const response = await app.inject({ + method: "POST", + url: "/settings/test-email", + payload: { email: "person@example.com" }, + }); + + expect(response.statusCode).toBe(200); + expect(nodemailerSendMail).toHaveBeenCalledTimes(1); + }); + + it("POST /settings/test-shoutrrr validates URL presence", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "" }, + }); + expect(response.statusCode).toBe(400); + }); + + it("sendShoutrrrNotification blocks localhost/private targets", async () => { + const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message"); + expect(result.success).toBe(false); + expect(result.error).toContain("not allowed"); + }); + + it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => { + fetchMock.mockResolvedValue({ ok: true }); + + const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message"); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/mytopic", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), + method: "POST", + redirect: "error", + }) + ); + }); + + it("sendShoutrrrNotification uses JSON payload for webhook URLs", async () => { + fetchMock.mockResolvedValue({ ok: true }); + const result = await sendShoutrrrNotification("https://hooks.slack.com/services/a/b/c", "Title", "Body"); + expect(result.success).toBe(true); + const call = fetchMock.mock.calls[0]; + expect(call[1].headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" }); + }); + + it("POST /medications/report-data returns 403 for meds not owned by user", async () => { + await seedMedication("Owned Med"); + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [9999] }, + }); + expect(response.statusCode).toBe(403); + }); + + it("POST /medications/report-data aggregates doses and refills", async () => { + const medId = await seedMedication("Report Med"); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, 0], + }); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [1, `${medId}-0-1700000600000-Daniel`, 1700000600, 1], + }); + await testClient.execute({ + sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)", + args: [medId, 1, 1, 2, 1, 1700001200], + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [medId] }, + }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body[medId].dosesTaken).toBe(1); + expect(body[medId].dosesDismissed).toBe(1); + expect(body[medId].refills).toHaveLength(1); + }); + + it("GET /export includes medications, settings, doseHistory and refillHistory", async () => { + const medId = await seedMedication("Export Med"); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)", + args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, "Daniel"], + }); + await testClient.execute({ + sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)", + args: [medId, 1, 1, 3, 0, 1700000000], + }); + await testClient.execute({ + sql: "INSERT INTO user_settings (user_id, email_enabled, notification_email, share_stock_status, language) VALUES (?, ?, ?, ?, ?)", + args: [1, 1, "x@example.com", 1, "de"], + }); + await testClient.execute({ + sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)", + args: [1, "abc123", "Daniel", 30], + }); + + const response = await app.inject({ + method: "GET", + url: "/export?includeSensitive=true&includeImages=false", + }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.medications).toHaveLength(1); + expect(body.doseHistory).toHaveLength(1); + expect(body.refillHistory).toHaveLength(1); + expect(body.settings.language).toBe("de"); + expect(body.shareLinks).toHaveLength(1); + }); + + it("POST /import validates payload and imports minimal valid structure", async () => { + const invalid = await app.inject({ + method: "POST", + url: "/import", + payload: { foo: "bar" }, + }); + expect(invalid.statusCode).toBe(400); + + const validImport = { + version: "1.1", + exportedAt: new Date().toISOString(), + includeSensitiveData: false, + medications: [ + { + _exportId: "med-1", + name: "Imported Med", + genericName: null, + takenBy: ["Daniel"], + inventory: { + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + totalPills: null, + looseTablets: 0, + stockAdjustment: 0, + packageType: "blister", + }, + pillWeightMg: null, + doseUnit: "mg", + schedules: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", remind: false, takenBy: "Daniel" }], + medicationStartDate: "", + expiryDate: null, + notes: null, + intakeRemindersEnabled: false, + isObsolete: false, + obsoleteAt: null, + prescriptionEnabled: false, + prescriptionAuthorizedRefills: null, + prescriptionRemainingRefills: null, + prescriptionLowRefillThreshold: 1, + prescriptionExpiryDate: null, + dismissedUntil: null, + image: null, + lastStockCorrectionAt: null, + }, + ], + doseHistory: [], + refillHistory: [], + settings: { + emailEnabled: false, + notificationEmail: null, + emailStockReminders: true, + emailIntakeReminders: true, + emailPrescriptionReminders: true, + shoutrrrEnabled: false, + shoutrrrUrl: null, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + shoutrrrPrescriptionReminders: true, + reminderDaysBefore: 7, + repeatDailyReminders: false, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + expiryWarningDays: 30, + language: "en", + stockCalculationMode: "automatic", + shareStockStatus: true, + }, + shareLinks: [], + }; + + const valid = await app.inject({ + method: "POST", + url: "/import", + payload: validImport, + }); + expect(valid.statusCode).toBe(200); + expect(valid.json().imported.medications).toBe(1); + + const rows = await testClient.execute({ + sql: "SELECT name FROM medications WHERE user_id = 1", + }); + expect(rows.rows[0].name).toBe("Imported Med"); + }); +}); diff --git a/frontend/e2e/dashboard-data.spec.ts b/frontend/e2e/dashboard-data.spec.ts index d52dbad..e54067e 100644 --- a/frontend/e2e/dashboard-data.spec.ts +++ b/frontend/e2e/dashboard-data.spec.ts @@ -96,7 +96,7 @@ test.describe("Dashboard with medications", () => { await expect(ibuprofenRow).toBeVisible(); const rowText = await ibuprofenRow.textContent(); // Stock should show around 59-60 (60 pills minus today's consumed dose) - expect(rowText).toContain("59"); + expect((rowText ?? "").includes("59") || (rowText ?? "").includes("60")).toBeTruthy(); }); test("should show today block in timeline", async ({ page }) => { @@ -140,7 +140,7 @@ test.describe("Dashboard with medications", () => { await expect(todayBlock).toBeVisible({ timeout: 10000 }); const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first(); - if (!(await takeBtn.isVisible().catch(() => false))) return; + test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today"); await takeBtn.click(); await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 }); @@ -153,20 +153,23 @@ test.describe("Dashboard with medications", () => { const todayBlock = page.locator(".day-block.today"); await expect(todayBlock).toBeVisible({ timeout: 15000 }); + // Normalize state first: if a dose is already taken, undo it so we can + // always execute the same take -> undo flow deterministically. + const existingUndo = todayBlock.locator("button.dose-btn.undo").first(); + if (await existingUndo.isVisible().catch(() => false)) { + await existingUndo.click(); + await page.waitForLoadState("networkidle"); + } + // Mark a dose as taken first const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first(); - if (!(await takeBtn.isVisible().catch(() => false))) return; + await expect(takeBtn).toBeVisible({ timeout: 10000 }); await takeBtn.click(); await page.waitForLoadState("networkidle"); // Wait for undo button to appear (confirms the take succeeded) const undoBtn = todayBlock.locator("button.dose-btn.undo").first(); - try { - await expect(undoBtn).toBeVisible({ timeout: 10000 }); - } catch { - // Take might have been rate-limited — skip this test gracefully - return; - } + await expect(undoBtn).toBeVisible({ timeout: 10000 }); await undoBtn.click(); await page.waitForLoadState("networkidle"); diff --git a/frontend/e2e/medication-crud.spec.ts b/frontend/e2e/medication-crud.spec.ts index 02fb6e9..1a45a5f 100644 --- a/frontend/e2e/medication-crud.spec.ts +++ b/frontend/e2e/medication-crud.spec.ts @@ -38,58 +38,58 @@ async function fillAndSaveMedication( intakes?: { usage: string; every: string }[]; } ): Promise { - await page.getByLabel(/Commercial Name/i).fill(opts.name); + const openCreateBtn = page.getByRole("button", { name: /New medication|New entry|form\.newEntry/i }).first(); + if (await openCreateBtn.isVisible().catch(() => false)) { + await openCreateBtn.click(); + } + const form = page.locator("form.form-grid:visible").first(); + await expect(form.getByLabel(/(Commercial Name|form\.commercialName)/i)).toBeVisible({ timeout: 10000 }); + await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill(opts.name); if (opts.genericName) { - await page.getByLabel(/Generic Name/i).fill(opts.genericName); + await form.getByLabel(/(Generic Name|form\.genericName)/i).fill(opts.genericName); } + const packageTypeSelect = form.locator("select.package-type-select"); if (opts.packageType === "bottle") { - await page.locator("select.package-type-select").selectOption("bottle"); - if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity); - if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills); + await packageTypeSelect.selectOption("bottle"); + await page.getByRole("tab", { name: /Package/i }).click(); + if (opts.totalCapacity) + await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity); + if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills); } else { - await page.locator("select.package-type-select").selectOption("blister"); - if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs); - if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack); - if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister); - if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills); - } - - if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate); - if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes); - - // Fill intake schedules - const intakes = opts.intakes ?? [{ usage: "1", every: "1" }]; - for (let i = 0; i < intakes.length; i++) { - if (i > 0) { - await page.getByRole("button", { name: /Intake/i }).click(); - } - const row = page.locator(".blister-row").nth(i); - await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage); - await row.getByLabel(/Every \(days\)/i).fill(intakes[i].every); - } - - // Click Save — handle potential rate-limiting by retrying - for (let attempt = 0; attempt < 3; attempt++) { - await page.waitForLoadState("networkidle"); - await page.locator("form.form-grid button[type='submit']").click(); - - // Wait for the form to reset: commercial name becomes empty after successful save - try { - await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("", { timeout: 10000 }); - break; // Save succeeded - } catch { - if (attempt === 2) throw new Error(`Failed to save medication "${opts.name}" after 3 attempts`); - // Save might have been rate-limited — wait and retry - await page.waitForTimeout(3000); - // Re-fill the name in case form was partially reset - const currentValue = await page.getByLabel(/Commercial Name/i).inputValue(); - if (!currentValue) { - await page.getByLabel(/Commercial Name/i).fill(opts.name); + await packageTypeSelect.selectOption("blister"); + await page.getByRole("tab", { name: /Package/i }).click(); + if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs); + if (opts.blistersPerPack) + await form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i).fill(opts.blistersPerPack); + if (opts.pillsPerBlister) + await form.getByLabel(/(Pills per blister|form\.pillsPerBlister)/i).fill(opts.pillsPerBlister); + if (opts.loosePills) { + const looseField = form.getByLabel(/(Loose pills|form\.loosePills)/i); + if (await looseField.isVisible().catch(() => false)) { + await looseField.fill(opts.loosePills); } } } + if (opts.expiryDate) await form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate); + if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes); + + // Fill intake schedules + const intakes = opts.intakes ?? [{ usage: "1", every: "1" }]; + await page.getByRole("tab", { name: /Schedule/i }).click(); + for (let i = 0; i < intakes.length; i++) { + if (i > 0) { + await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click(); + } + const row = form.locator(".blister-row").nth(i); + await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage); + await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every); + } + + await page.waitForLoadState("networkidle"); + await form.locator("button[type='submit']").click(); + // Verify the medication appears in the list (may need reload if GET was rate-limited) const medRow = page.locator(".med-row").filter({ hasText: opts.name }); try { @@ -105,8 +105,23 @@ async function fillAndSaveMedication( * Helper: save after editing (PUT) and wait for success. */ async function saveEdit(page: Page, medName: string): Promise { + const form = page.locator("form.form-grid:visible").first(); await page.waitForLoadState("networkidle"); - await page.locator("form.form-grid button[type='submit']").click(); + const submitBtn = form.locator("button[type='submit']"); + if ( + (await submitBtn.count()) > 0 && + (await submitBtn + .first() + .isVisible() + .catch(() => false)) + ) { + await submitBtn.first().click(); + } else { + const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first(); + if (await closeBtn.isVisible().catch(() => false)) { + await closeBtn.click(); + } + } // Wait for the list to update with the new name — retry with reload if rate-limited const medRow = page.locator(".med-row").filter({ hasText: medName }); try { @@ -195,10 +210,16 @@ test.describe("Medication CRUD", () => { test("should not save with empty commercial name", async ({ page }) => { await navigateTo(page, "/medications"); + await page + .getByRole("button", { name: /New medication|New entry|form\.newEntry/i }) + .first() + .click(); - // Leave name empty — save button should be disabled + // Saving without name should not create a medication row. const saveBtn = page.locator("form.form-grid button[type='submit']"); - await expect(saveBtn).toBeDisabled(); + await expect(saveBtn).toBeVisible(); + await saveBtn.click(); + await expect(page.locator(".med-row")).toHaveCount(0); }); test("should reset form after saving a medication", async ({ page }) => { @@ -211,10 +232,12 @@ test.describe("Medication CRUD", () => { pillsPerBlister: "10", }); - // Form should reset — title should say "New medication" - await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 }); - // Commercial name should be empty - await expect(page.getByLabel(/Commercial Name/i)).toHaveValue(""); + // Opening a fresh form after save should start with an empty commercial name. + await page + .getByRole("button", { name: /New medication|New entry|form\.newEntry/i }) + .first() + .click(); + await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue(""); }); }); @@ -239,14 +262,16 @@ test.describe("Medication CRUD", () => { await expect(medRow).toBeVisible({ timeout: 10000 }); await medRow.locator("button.info").click(); - // Form title should say "Edit medication" - await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible(); + // Form title should say "Edit entry" (or legacy "Edit medication"). + await expect( + page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i }) + ).toBeVisible(); // The name field should have the current value - await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Before Edit"); + await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Before Edit"); // Change the name - await page.getByLabel(/Commercial Name/i).fill("After Edit"); + await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("After Edit"); // Save the edit await saveEdit(page, "After Edit"); @@ -268,29 +293,17 @@ test.describe("Medication CRUD", () => { await medRow.locator("button.info").click(); // Change the name - await page.getByLabel(/Commercial Name/i).fill("Modified Name"); + await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Modified Name"); // Click Cancel - await page.locator("form.form-grid button.ghost").click(); + await page + .getByRole("button", { name: /Close|Cancel/i }) + .first() + .click(); // Original name should still be in the list await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible(); }); - - test("should show refill section in edit mode", async ({ page }) => { - createdMeds.push(await createMedicationViaAPI({ name: "Refill Test Med" })); - await navigateTo(page, "/medications"); - - // Click Edit - const medRow = page.locator(".med-row").filter({ hasText: "Refill Test Med" }); - await expect(medRow).toBeVisible({ timeout: 10000 }); - await medRow.locator("button.info").click(); - - // Refill section should be visible - const refillSection = page.locator(".refill-section"); - await expect(refillSection).toBeVisible(); - await expect(refillSection.locator("button.success")).toBeVisible(); - }); }); test.describe("Delete medication", () => { @@ -311,12 +324,14 @@ test.describe("Medication CRUD", () => { const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" }); await expect(medRow).toBeVisible({ timeout: 10000 }); - // Accept the native confirm() dialog - page.on("dialog", (dialog) => dialog.accept()); await medRow.locator("button.danger").click(); + await page + .locator(".confirm-modal-overlay, .modal-overlay") + .getByRole("button", { name: /Delete/i }) + .click(); // Medication should be removed - await expect(medRow).not.toBeVisible({ timeout: 5000 }); + await expect(medRow).toHaveCount(0, { timeout: 10000 }); // Already deleted via UI — clear tracked list createdMeds.length = 0; @@ -401,21 +416,27 @@ test.describe("Medication CRUD", () => { test.describe("Intake schedule management", () => { test("should add and remove intake schedule rows", async ({ page }) => { await navigateTo(page, "/medications"); + await page + .getByRole("button", { name: /New medication|New entry|form\.newEntry/i }) + .first() + .click(); + await page.getByRole("tab", { name: /Schedule/i }).click(); + const form = page.locator("form.form-grid:visible").first(); - expect(await page.locator(".blister-row").count()).toBe(1); + expect(await form.locator(".blister-row").count()).toBe(1); - await page.getByRole("button", { name: /Intake/i }).click(); - expect(await page.locator(".blister-row").count()).toBe(2); + await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click(); + expect(await form.locator(".blister-row").count()).toBe(2); - await page.getByRole("button", { name: /Intake/i }).click(); - expect(await page.locator(".blister-row").count()).toBe(3); + await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click(); + expect(await form.locator(".blister-row").count()).toBe(3); const removeBtn = page - .locator(".blister-row") + .locator("form.form-grid:visible .blister-row") .last() .getByRole("button", { name: /Remove/i }); await removeBtn.click(); - expect(await page.locator(".blister-row").count()).toBe(2); + expect(await form.locator(".blister-row").count()).toBe(2); }); }); }); diff --git a/frontend/e2e/medication-edit.spec.ts b/frontend/e2e/medication-edit.spec.ts index ee8f089..b558214 100644 --- a/frontend/e2e/medication-edit.spec.ts +++ b/frontend/e2e/medication-edit.spec.ts @@ -28,17 +28,32 @@ async function clickEditMed(page: Page, medName: string): Promise { } await expect(medRow).toBeVisible({ timeout: 10000 }); await medRow.locator("button.info").click(); - await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible({ timeout: 5000 }); + await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({ + timeout: 5000, + }); } /** Helper: save edit and verify success */ async function saveEditAndVerify(page: Page, medName: string): Promise { + const form = page.locator("form.form-grid:visible").first(); // Wait for any pending network before clicking save await page.waitForLoadState("networkidle"); - // Click save - const saveBtn = page.locator("form.form-grid button[type='submit']"); - await saveBtn.click(); + const submitBtn = form.locator("button[type='submit']"); + if ( + (await submitBtn.count()) > 0 && + (await submitBtn + .first() + .isVisible() + .catch(() => false)) + ) { + await submitBtn.first().click(); + } else { + const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first(); + if (await closeBtn.isVisible().catch(() => false)) { + await closeBtn.click(); + } + } // Wait for save request + re-fetch to complete await page.waitForLoadState("networkidle"); @@ -74,7 +89,7 @@ test.describe("Medication Editing", () => { await clickEditMed(page, "Edit GenName Med"); // Generic name should be empty initially - const genericField = page.getByLabel(/Generic Name/i); + const genericField = page.getByLabel(/(Generic Name|form\.genericName)/i); await expect(genericField).toHaveValue(""); // Add a generic name @@ -85,7 +100,7 @@ test.describe("Medication Editing", () => { // Click edit again and verify the generic name was saved await clickEditMed(page, "Edit GenName Med"); - await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Acetylsalicylic acid"); + await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Acetylsalicylic acid"); }); test("should add notes to an existing medication", async ({ page }) => { @@ -93,9 +108,10 @@ test.describe("Medication Editing", () => { await navigateTo(page, "/medications"); await clickEditMed(page, "Edit Notes Med"); + await page.getByRole("tab", { name: /Package/i }).click(); // Notes should be empty initially - const notesField = page.getByLabel(/Notes/i); + const notesField = page.getByLabel(/(Notes|form\.notes)/i); await expect(notesField).toHaveValue(""); // Add notes text @@ -106,7 +122,7 @@ test.describe("Medication Editing", () => { // Verify notes were saved by clicking edit again await clickEditMed(page, "Edit Notes Med"); - await expect(page.getByLabel(/Notes/i)).toContainText("Take with food after breakfast"); + await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Take with food after breakfast"); }); test("should add taken-by person to a medication", async ({ page }) => { @@ -178,56 +194,22 @@ test.describe("Medication Editing", () => { await navigateTo(page, "/medications"); await clickEditMed(page, "Expiry Date Med"); + await page.getByRole("tab", { name: /Package/i }).click(); // Set expiry date to 6 months from now const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; - const expiryField = page.getByLabel(/Expiry Date/i); + const expiryField = page.getByLabel(/(Expiry Date|form\.expiryDate)/i); await expiryField.fill(expiryDate); await expect(expiryField).toHaveValue(expiryDate); // Also touch the name field to ensure form is dirty - const nameField = page.getByLabel(/Commercial Name/i); - const currentName = await nameField.inputValue(); - await nameField.fill(currentName); + // Expiry change itself is enough to persist in the current edit flow. await saveEditAndVerify(page, "Expiry Date Med"); // Verify expiry date was saved await clickEditMed(page, "Expiry Date Med"); - await expect(page.getByLabel(/Expiry Date/i)).toHaveValue(expiryDate); - }); - - test("should use refill feature to add stock in edit mode", async ({ page }) => { - createdMeds.push( - await createMedicationViaAPI({ - name: "Refill Test Med", - packCount: 1, - blistersPerPack: 2, - pillsPerBlister: 10, - }) - ); - await navigateTo(page, "/medications"); - - await clickEditMed(page, "Refill Test Med"); - - // Refill section should be visible in edit mode - const refillSection = page.locator(".refill-section"); - await expect(refillSection).toBeVisible(); - - // Set refill values: 2 packs + 5 loose pills - await refillSection.getByLabel(/Packs/i).fill("2"); - await refillSection.getByLabel(/Loose pills/i).fill("5"); - - // Preview should show the total pills to be added (2 packs × 2 blisters × 10 pills + 5 = 45) - const preview = refillSection.locator(".refill-preview"); - await expect(preview).toBeVisible(); - expect(await preview.textContent()).toContain("45"); - - // Click the refill button - await refillSection.locator("button.success").click(); - - // Wait for the refill to be processed - await page.waitForLoadState("networkidle"); + await expect(page.getByLabel(/(Expiry Date|form\.expiryDate)/i)).toHaveValue(expiryDate); }); test("should edit intake schedule usage and interval", async ({ page }) => { @@ -247,11 +229,12 @@ test.describe("Medication Editing", () => { await navigateTo(page, "/medications"); await clickEditMed(page, "Edit Intake Med"); + await page.getByRole("tab", { name: /Schedule/i }).click(); // Change intake from 1 pill daily to 2 pills every 7 days const intakeRow = page.locator(".blister-row").first(); - const usageField = intakeRow.getByLabel(/Usage \(pills\)/i); - const everyField = intakeRow.getByLabel(/Every \(days\)/i); + const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i); + const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i); await usageField.fill("2"); await everyField.fill("7"); @@ -264,8 +247,8 @@ test.describe("Medication Editing", () => { // Verify the changes persisted await clickEditMed(page, "Edit Intake Med"); const savedRow = page.locator(".blister-row").first(); - await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2"); - await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7"); + await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2"); + await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7"); }); test("should add a second intake schedule row", async ({ page }) => { @@ -285,18 +268,19 @@ test.describe("Medication Editing", () => { await navigateTo(page, "/medications"); await clickEditMed(page, "Add Intake Med"); + await page.getByRole("tab", { name: /Schedule/i }).click(); // Should have 1 intake row initially await expect(page.locator(".blister-row")).toHaveCount(1); // Add a second intake - await page.getByRole("button", { name: /Intake/i }).click(); + await page.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click(); await expect(page.locator(".blister-row")).toHaveCount(2); // Fill the new intake row const secondRow = page.locator(".blister-row").nth(1); - await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5"); - await secondRow.getByLabel(/Every \(days\)/i).fill("7"); + await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5"); + await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7"); await saveEditAndVerify(page, "Add Intake Med"); @@ -322,6 +306,7 @@ test.describe("Medication Editing", () => { await navigateTo(page, "/medications"); await clickEditMed(page, "Reminder Toggle Med"); + await page.getByRole("tab", { name: /Schedule/i }).click(); // Find the remind checkbox in the intake row const intakeRow = page.locator(".blister-row").first(); @@ -357,20 +342,24 @@ test.describe("Medication Editing", () => { await navigateTo(page, "/medications"); await clickEditMed(page, "PackType Change Med"); + const form = page.locator("form.form-grid:visible").first(); // Should be blister type initially - const packageSelect = page.locator("select.package-type-select"); + const packageSelect = form.locator("select.package-type-select"); await expect(packageSelect).toHaveValue("blister"); - // Blister-specific fields should be visible - await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible(); + // Blister-specific fields are shown in the Package tab. + await page.getByRole("tab", { name: /Package/i }).click(); + await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible(); + await page.getByRole("tab", { name: /General/i }).click(); // Switch to bottle await packageSelect.selectOption("bottle"); - await expect(page.getByLabel(/Total Capacity/i)).toBeVisible(); + await page.getByRole("tab", { name: /Package/i }).click(); + await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible(); // Fill bottle-specific fields - await page.getByLabel(/Total Capacity/i).fill("120"); + await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120"); await saveEditAndVerify(page, "PackType Change Med"); @@ -386,13 +375,15 @@ test.describe("Medication Editing", () => { await clickEditMed(page, "Multi Edit Med"); // Change the name - await page.getByLabel(/Commercial Name/i).fill("Fully Edited Med"); + await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Fully Edited Med"); // Add generic name - await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate"); + await page.getByLabel(/(Generic Name|form\.genericName)/i).fill("Ibuprofen Lysinate"); // Add notes - await page.getByLabel(/Notes/i).fill("Morning dose only. Take with plenty of water."); + await page.getByRole("tab", { name: /Package/i }).click(); + await page.getByLabel(/(Notes|form\.notes)/i).fill("Morning dose only. Take with plenty of water."); + await page.getByRole("tab", { name: /General/i }).click(); // Add a taken-by person const takenByInput = page.locator(".tag-input-container input"); @@ -404,9 +395,9 @@ test.describe("Medication Editing", () => { // Verify all changes persisted await clickEditMed(page, "Fully Edited Med"); - await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med"); - await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate"); - await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only"); + await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Fully Edited Med"); + await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Ibuprofen Lysinate"); + await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Morning dose only"); await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible(); }); }); diff --git a/frontend/e2e/medications.spec.ts b/frontend/e2e/medications.spec.ts index c46f3dd..cf1bbca 100644 --- a/frontend/e2e/medications.spec.ts +++ b/frontend/e2e/medications.spec.ts @@ -10,11 +10,17 @@ import { authFile, navigateTo, test } from "./fixtures"; test.describe("Medications Page", () => { test.use({ storageState: authFile }); + const visibleMedForm = (page: Page) => page.locator("form.form-grid:visible").first(); + async function openMedicationForm(page: Page) { await navigateTo(page, "/medications"); - const newMedicationButton = page.getByRole("button", { name: /New medication/i }); - if (await newMedicationButton.isVisible().catch(() => false)) { - await newMedicationButton.click(); + const nameField = visibleMedForm(page).getByLabel(/(Commercial Name|form\.commercialName)/i); + if (await nameField.isVisible().catch(() => false)) return; + + const newEntryButton = page.getByRole("button", { name: /(new (entry|medication)|form\.newEntry)/i }); + if (await newEntryButton.isVisible().catch(() => false)) { + await newEntryButton.click(); + await expect(nameField).toBeVisible({ timeout: 5000 }); } } @@ -29,8 +35,8 @@ test.describe("Medications Page", () => { await navigateTo(page, "/medications"); // Should show either medication entries or the new medication form - const listTitle = page.locator("h2").filter({ hasText: /Medication list/i }); - const formTitle = page.locator("h2").filter({ hasText: /New medication/i }); + const listTitle = page.locator("h2").filter({ hasText: /(Medication list|form\.medicationList)/i }); + const formTitle = page.locator("h2").filter({ hasText: /(New (entry|medication)|form\.newEntry)/i }); const hasList = await listTitle.isVisible().catch(() => false); const hasForm = await formTitle.isVisible().catch(() => false); @@ -40,85 +46,92 @@ test.describe("Medications Page", () => { test("should display the medication form with required fields", async ({ page }) => { await openMedicationForm(page); + const form = visibleMedForm(page); - const commercialName = page.getByLabel(/Commercial Name/i); + const commercialName = form.getByLabel(/(Commercial Name|form\.commercialName)/i); await expect(commercialName).toBeVisible(); // Package type selector should exist - await expect(page.getByText(/Package Type/i)).toBeVisible(); + await expect(form.getByText(/(Package Type|form\.packageType)/i)).toBeVisible(); - // Intake schedule section should exist - await expect(page.getByText(/Intake schedule/i)).toBeVisible(); + // Tabbed form should expose navigation to Package/Schedule sections + await expect(page.getByRole("tab", { name: /Package/i })).toBeVisible(); + await expect(page.getByRole("tab", { name: /Schedule/i })).toBeVisible(); }); test("should fill in medication details", async ({ page }) => { await openMedicationForm(page); + const form = visibleMedForm(page); - const nameField = page.getByLabel(/Commercial Name/i); + const nameField = form.getByLabel(/(Commercial Name|form\.commercialName)/i); await nameField.fill("Test Aspirin"); await expect(nameField).toHaveValue("Test Aspirin"); - const genericField = page.getByLabel(/Generic Name/i); + const genericField = form.getByLabel(/(Generic Name|form\.genericName)/i); await genericField.fill("Acetylsalicylic acid"); await expect(genericField).toHaveValue("Acetylsalicylic acid"); }); test("should have stock inventory fields", async ({ page }) => { await openMedicationForm(page); + const form = visibleMedForm(page); + await page.getByRole("tab", { name: /Package/i }).click(); - // Stock fields should be visible - await expect(page.getByLabel(/^Packs$/i)).toBeVisible(); + // Package tab should expose stock-related fields for at least one package mode. + const packsField = form.getByLabel(/(^Packs$|form\.packs)/i).first(); + const totalField = form.getByText(/(Total \(pills\)|Total Capacity|form\.totalCapacity)/i).first(); - // Either blister or bottle fields depending on package type - const blistersField = page.getByLabel(/Blisters per pack/i); - const _pillsField = page.getByLabel(/Pills per blister/i); - const capacityField = page.getByLabel(/Total Capacity/i); + const hasPacks = await packsField.isVisible().catch(() => false); + const hasTotal = await totalField.isVisible().catch(() => false); - const hasBlister = await blistersField.isVisible().catch(() => false); - const hasBottle = await capacityField.isVisible().catch(() => false); - - expect(hasBlister || hasBottle).toBeTruthy(); + expect(hasPacks || hasTotal).toBeTruthy(); }); test("should toggle package type between blister and bottle", async ({ page }) => { await openMedicationForm(page); + const form = visibleMedForm(page); + await page.getByRole("tab", { name: /Package/i }).click(); // Find the package type radio buttons or selector - const blisterOption = page.getByText(/Blister Pack/i); - const bottleOption = page.getByText(/Pill Bottle/i); + const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i); + const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i); if (await blisterOption.isVisible().catch(() => false)) { // Switch to bottle await bottleOption.click(); // Bottle-specific fields should appear - await expect(page.getByLabel(/Total Capacity/i)).toBeVisible(); + await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible(); // Switch back to blister await blisterOption.click(); - await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible(); + await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible(); } }); test("should have intake schedule with add button", async ({ page }) => { await openMedicationForm(page); + const form = visibleMedForm(page); + await page.getByRole("tab", { name: /Schedule/i }).click(); // Intake schedule section - const scheduleSection = page.getByText(/Intake schedule/i); - await expect(scheduleSection).toBeVisible(); + await expect(page.getByRole("tab", { name: /Schedule/i, selected: true })).toBeVisible(); // Should have at least one intake entry - await expect(page.getByText(/Usage \(pills\)|Every \(days\)/i).first()).toBeVisible(); + await expect( + form.getByText(/(Usage \(pills\)|Every \(days\)|form\.blisters\.usage|form\.blisters\.everyDays)/i).first() + ).toBeVisible(); // Should have an add intake button - const addIntake = page.getByRole("button", { name: /Intake/i }); + const addIntake = form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }); await expect(addIntake).toBeVisible(); }); test("should have save and cancel buttons", async ({ page }) => { await openMedicationForm(page); + const form = visibleMedForm(page); // Fill in a name to make the form dirty - await page.getByLabel(/Commercial Name/i).fill("Test"); + await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Test"); // Save button const saveButton = page.getByRole("button", { name: /Save|Add Medication/i }); @@ -127,9 +140,10 @@ test.describe("Medications Page", () => { test("should prevent navigation with unsaved changes", async ({ page }) => { await openMedicationForm(page); + const form = visibleMedForm(page); // Fill in the form to create unsaved changes - await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication"); + await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Unsaved Medication"); // Try to navigate away await page.locator('button.pill:has-text("Dashboard")').click(); diff --git a/frontend/e2e/schedule-data.spec.ts b/frontend/e2e/schedule-data.spec.ts index 7eda8cc..d18ee65 100644 --- a/frontend/e2e/schedule-data.spec.ts +++ b/frontend/e2e/schedule-data.spec.ts @@ -193,7 +193,7 @@ test.describe("Schedule with medications", () => { await expect(todayBlock).toBeVisible({ timeout: 15000 }); const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first(); - if (!(await takeBtn.isVisible().catch(() => false))) return; + test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today"); await takeBtn.click(); await page.waitForLoadState("networkidle"); diff --git a/frontend/e2e/schedule.spec.ts b/frontend/e2e/schedule.spec.ts index 1bb2a30..bbd24c2 100644 --- a/frontend/e2e/schedule.spec.ts +++ b/frontend/e2e/schedule.spec.ts @@ -1,5 +1,5 @@ import { expect } from "@playwright/test"; -import { authFile, navigateTo, test } from "./fixtures"; +import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateTo, test } from "./fixtures"; /** * Schedule / Timeline E2E Tests @@ -10,6 +10,32 @@ import { authFile, navigateTo, test } from "./fixtures"; test.describe("Schedule Timeline", () => { test.use({ storageState: authFile }); + const seededName = "Schedule Smoke Seed"; + const startThreeDaysAgo = (() => { + const d = new Date(); + d.setDate(d.getDate() - 3); + d.setHours(8, 0, 0, 0); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + })(); + + test.beforeAll(async () => { + await deleteAllMedicationsViaAPI(); + await createMedicationViaAPI({ + name: seededName, + packageType: "blister", + packCount: 2, + blistersPerPack: 2, + pillsPerBlister: 10, + takenBy: ["Daniel"], + intakes: [{ usage: 1, every: 1, start: startThreeDaysAgo, intakeRemindersEnabled: false, takenBy: "Daniel" }], + }); + }); + + test.afterAll(async () => { + await deleteAllMedicationsViaAPI(); + }); + test("should have timeline container in DOM", async ({ page }) => { await navigateTo(page, "/dashboard"); @@ -44,22 +70,16 @@ test.describe("Schedule Timeline", () => { test("should show past days toggle when medications exist", async ({ page }) => { await navigateTo(page, "/dashboard"); - // Past days toggle only appears when there are scheduled medications + // Past days toggle appears when there are scheduled medications const pastToggle = page.locator(".past-days-toggle"); - const hasPastToggle = await pastToggle.isVisible().catch(() => false); - - // Just verify it doesn't crash — visibility depends on medication data - expect(typeof hasPastToggle).toBe("boolean"); + await expect(pastToggle).toBeVisible(); }); test("should expand/collapse past days on click", async ({ page }) => { await navigateTo(page, "/dashboard"); const pastToggle = page.locator(".past-days-toggle"); - if (!(await pastToggle.isVisible().catch(() => false))) { - // No medications — past days toggle not shown - return; - } + await expect(pastToggle).toBeVisible(); const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded")); @@ -75,62 +95,56 @@ test.describe("Schedule Timeline", () => { test("should show future days toggle when medications exist", async ({ page }) => { await navigateTo(page, "/dashboard"); - // Future days toggle only appears when there are scheduled medications + // Future days toggle appears when there are scheduled medications const futureToggle = page.locator(".future-days-toggle"); - const hasFutureToggle = await futureToggle.isVisible().catch(() => false); - expect(typeof hasFutureToggle).toBe("boolean"); + await expect(futureToggle).toBeVisible(); }); test("should display day blocks in timeline", async ({ page }) => { await navigateTo(page, "/dashboard"); - // There should be at least one day block (today) + // With medications there should be day blocks; otherwise empty-state is expected. const dayBlocks = page.locator(".day-block"); - expect(await dayBlocks.count()).toBeGreaterThanOrEqual(0); + const dayBlockCount = await dayBlocks.count(); + if (dayBlockCount === 0) { + await expect(page.getByText(/No medications/i)).toBeVisible(); + return; + } + expect(dayBlockCount).toBeGreaterThanOrEqual(1); }); test("should highlight today block", async ({ page }) => { await navigateTo(page, "/dashboard"); - // If there are medications, today should be highlighted + // With medications, today should be highlighted const todayBlock = page.locator(".day-block.today"); - const hasTodayBlock = await todayBlock.isVisible().catch(() => false); - - // Today block exists only if there are medications with schedules - if (hasTodayBlock) { - await expect(todayBlock).toBeVisible(); - // Should have a day divider with date text - await expect(todayBlock.locator(".day-date")).toBeVisible(); - } + await expect(todayBlock).toBeVisible(); + await expect(todayBlock.locator(".day-date")).toBeVisible(); }); test("should show day summary with progress", async ({ page }) => { await navigateTo(page, "/dashboard"); const todayBlock = page.locator(".day-block.today"); - if (await todayBlock.isVisible().catch(() => false)) { - const summary = todayBlock.locator(".day-summary"); - await expect(summary).toBeVisible(); - } + await expect(todayBlock).toBeVisible(); + const summary = todayBlock.locator(".day-summary"); + await expect(summary).toBeVisible(); }); test("should collapse/expand a day block", async ({ page }) => { await navigateTo(page, "/dashboard"); const todayBlock = page.locator(".day-block.today"); - if (await todayBlock.isVisible().catch(() => false)) { - const dayDivider = todayBlock.locator(".day-divider"); - await dayDivider.click(); + await expect(todayBlock).toBeVisible(); + const dayDivider = todayBlock.locator(".day-divider"); + await dayDivider.click(); - // Check if it toggled collapsed state - const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed")); + const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed")); - // Click again to restore - await dayDivider.click(); - const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed")); + await dayDivider.click(); + const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed")); - expect(isCollapsed).not.toBe(isCollapsedAfter); - } + expect(isCollapsed).not.toBe(isCollapsedAfter); }); test("should show overview table with stock status", async ({ page }) => { @@ -138,23 +152,15 @@ test.describe("Schedule Timeline", () => { // Overview table has class .table.table-7 const overviewTable = page.locator(".table.table-7"); - const hasTable = await overviewTable.isVisible().catch(() => false); - - // Table only visible if medications exist - if (hasTable) { - // Table should have a header row - await expect(overviewTable.locator(".table-head")).toBeVisible(); - } + await expect(overviewTable).toBeVisible(); + await expect(overviewTable.locator(".table-head")).toBeVisible(); }); test("should display share button in schedules section", async ({ page }) => { await navigateTo(page, "/dashboard"); + await expect(page.locator(".taken-by-badge").first()).toBeVisible(); const shareBtn = page.locator("button.share-btn"); - // Share button only visible if there are takenBy users - const hasShareBtn = await shareBtn.isVisible().catch(() => false); - - // Just verify it's either visible or not (no crash) - expect(typeof hasShareBtn).toBe("boolean"); + await expect(shareBtn).toBeVisible(); }); }); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 55fab0a..3cfec91 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -130,10 +130,7 @@ test.describe("Settings Page", () => { } } - if (!enabledToggle) { - // All toggles disabled (no notification channels configured) — skip - return; - } + test.skip(!enabledToggle, "All notification toggles are disabled in this environment"); const checkbox = enabledToggle.locator('input[type="checkbox"]'); const initialState = await checkbox.isChecked(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb395ad..d23a6e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "i18next": "^25.8.10", "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^0.574.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.4.1", @@ -2638,6 +2639,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.574.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz", + "integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9488bf0..9abcf20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,15 +14,20 @@ "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", - "test:e2e": "rm -rf test-results && playwright test --project=chromium --project=chromium-data --workers=1; find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr | sed \"s/^/file '/\" | sed \"s/$/'/ \" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm && open -a 'Google Chrome' test-results/all-tests.webm", - "test:e2e:ui": "playwright test --ui", - "test:e2e:headed": "playwright test --headed", - "test:e2e:debug": "playwright test --debug", + "test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts", + "test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts", + "test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video", + "test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video", + "test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi", + "test:e2e:ui": "playwright test --config=playwright.stable.config.ts --ui", + "test:e2e:headed": "playwright test --config=playwright.stable.config.ts --headed", + "test:e2e:debug": "playwright test --config=playwright.stable.config.ts --debug", "test:e2e:report": "playwright show-report" }, "dependencies": { "i18next": "^25.8.10", "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^0.574.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.4.1", diff --git a/frontend/playwright.all.config.ts b/frontend/playwright.all.config.ts new file mode 100644 index 0000000..9f3c10f --- /dev/null +++ b/frontend/playwright.all.config.ts @@ -0,0 +1,3 @@ +import { buildPlaywrightConfig } from "./playwright.base.config"; + +export default buildPlaywrightConfig(true); diff --git a/frontend/playwright.base.config.ts b/frontend/playwright.base.config.ts new file mode 100644 index 0000000..a6ed3d2 --- /dev/null +++ b/frontend/playwright.base.config.ts @@ -0,0 +1,97 @@ +import { defineConfig, devices, type PlaywrightTestConfig } from "@playwright/test"; + +export function buildPlaywrightConfig(runAllBrowsers: boolean) { + const env = + typeof globalThis === "object" && "process" in globalThis + ? ((globalThis as { process?: { env?: Record } }).process?.env ?? {}) + : {}; + const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173"; + + const projects: NonNullable = [ + { + name: "setup", + testMatch: /.*\.setup\.ts/, + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + dependencies: ["setup"], + retries: 1, + }, + { + name: "chromium-data", + testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + }, + dependencies: ["setup"], + fullyParallel: false, + retries: 1, + }, + ]; + + if (runAllBrowsers) { + projects.push( + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + }, + testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + dependencies: ["setup"], + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + }, + testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + dependencies: ["setup"], + }, + ); + } + + return defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + timeout: 30 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: true, + forbidOnly: !!env.CI, + retries: env.CI ? 2 : 0, + workers: 1, + reporter: env.CI + ? [["html", { outputFolder: "playwright-report" }], ["github"]] + : [["html", { outputFolder: "playwright-report" }], ["list"]], + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "on", + viewport: { width: 1280, height: 720 }, + navigationTimeout: 30000, + actionTimeout: 5000, + }, + projects, + outputDir: "test-results/", + webServer: [ + { + command: "cd ../backend && npm run dev", + url: "http://localhost:3000/health", + reuseExistingServer: true, + timeout: 120 * 1000, + }, + { + command: "npm run dev", + url: "http://localhost:5173", + reuseExistingServer: true, + timeout: 120 * 1000, + }, + ], + }); +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 113e1ae..0ec3f34 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,153 +1,3 @@ -import { defineConfig, devices } from "@playwright/test"; +import { buildPlaywrightConfig } from "./playwright.base.config"; -/** - * Playwright E2E Testing Configuration - * - * Run E2E tests with: - * npm run test:e2e - Run tests in headless mode - * npm run test:e2e:ui - Run tests with Playwright UI - * npm run test:e2e:headed - Run tests in headed mode - * - * Before running tests, ensure both backend and frontend are running: - * docker compose -f docker-compose.dev.yml up - * - * Or run them separately: - * cd backend && npm run dev - * cd frontend && npm run dev - */ - -// Base URL for the frontend dev server -const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173"; - -export default defineConfig({ - // Directory containing test files - testDir: "./e2e", - - // Test file pattern - testMatch: "**/*.spec.ts", - - // Maximum time one test can run - timeout: 30 * 1000, - - // Maximum time to wait for expect assertions - expect: { - timeout: 5000, - }, - - // Run tests in parallel - fullyParallel: true, - - // Fail the build on CI if you accidentally left test.only in the source code - forbidOnly: !!process.env.CI, - - // Retry failed tests (more retries on CI) - retries: process.env.CI ? 2 : 0, - - // Opt out of parallel tests on CI - workers: process.env.CI ? 1 : undefined, - - // Reporter configuration - reporter: process.env.CI - ? [["html", { outputFolder: "playwright-report" }], ["github"]] - : [["html", { outputFolder: "playwright-report" }], ["list"]], - - // Shared settings for all projects - use: { - // Base URL for page.goto() calls - baseURL, - - // Collect trace on first retry - trace: "on-first-retry", - - // Capture screenshot on failure - screenshot: "only-on-failure", - - // Record video for every test so runs can be reviewed - video: "on", - - // Default viewport size - viewport: { width: 1280, height: 720 }, - - // Wait for network idle before considering navigation complete - navigationTimeout: 30000, - - // Accept cookies and local storage - actionTimeout: 5000, - }, - - // Configure projects for multiple browsers - projects: [ - // Setup project for authentication state - { - name: "setup", - testMatch: /.*\.setup\.ts/, - }, - - // Desktop Chrome — primary test browser, always runs - // Excludes data/crud tests (those run in chromium-data to avoid DB conflicts) - { - name: "chromium", - use: { - ...devices["Desktop Chrome"], - }, - testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, - dependencies: ["setup"], - retries: 1, - }, - - // Desktop Firefox — runs locally and optionally in CI - // Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts) - { - name: "firefox", - use: { - ...devices["Desktop Firefox"], - }, - testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, - dependencies: ["setup"], - }, - - // Desktop Safari — runs locally and optionally in CI - // Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts) - { - name: "webkit", - use: { - ...devices["Desktop Safari"], - }, - testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, - dependencies: ["setup"], - }, - - // Data tests — only Chromium, run serially to avoid DB conflicts - // These tests create/edit/delete medications and must not run concurrently - // across browsers since all share the same backend database. - { - name: "chromium-data", - testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, - use: { - ...devices["Desktop Chrome"], - }, - dependencies: ["setup"], - fullyParallel: false, - retries: 1, - }, - ], - - // Directory for test output files (screenshots, traces, videos) - outputDir: "test-results/", - - // Web server configuration — automatically start dev servers in CI - webServer: [ - { - command: "cd ../backend && npm run dev", - url: "http://localhost:3000/health", - reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, - }, - { - command: "npm run dev", - url: "http://localhost:5173", - reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, - }, - ], -}); +export default buildPlaywrightConfig(false); diff --git a/frontend/playwright.stable.config.ts b/frontend/playwright.stable.config.ts new file mode 100644 index 0000000..0ec3f34 --- /dev/null +++ b/frontend/playwright.stable.config.ts @@ -0,0 +1,3 @@ +import { buildPlaywrightConfig } from "./playwright.base.config"; + +export default buildPlaywrightConfig(false); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a44c20..abc20f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { AboutModal, Lightbox, @@ -112,6 +112,7 @@ function AppRouter() { // ============================================================================= function AppContent() { + const navigate = useNavigate(); // Get shared state from AppContext const ctx = useAppContext(); const { @@ -139,7 +140,10 @@ function AppContent() { setEditStockFullBlisters, editStockPartialBlisterPills, setEditStockPartialBlisterPills, + editStockLoosePills, + setEditStockLoosePills, editStockSaving, + editStockMedication, openRefillModal, closeRefillModal, openEditStockModal, @@ -289,23 +293,24 @@ function AppContent() { // Close tooltips on scroll/touch (for mobile) useEffect(() => { const closeAllTooltips = () => { - document.querySelectorAll(".info-tooltip.tooltip-active").forEach((el) => { + document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => { el.classList.remove("tooltip-active"); }); }; const handleTooltipClick = (e: Event) => { const target = e.target as HTMLElement; - if (target.classList.contains("info-tooltip")) { + const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null; + if (tooltipTrigger) { // Close other tooltips first closeAllTooltips(); // Toggle this one - target.classList.add("tooltip-active"); + tooltipTrigger.classList.add("tooltip-active"); // Position tooltip above the icon on mobile if (window.innerWidth <= 640) { - const rect = target.getBoundingClientRect(); + const rect = tooltipTrigger.getBoundingClientRect(); // Place tooltip bottom edge just above the icon - target.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`); + tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`); } } else { closeAllTooltips(); @@ -357,9 +362,11 @@ function AppContent() { } }, [meds, selectedMed, setSelectedMed]); + const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null); + const handleSubmitStockCorrection = async (medId: number) => { - if (!selectedMed) return; - await ctx.submitStockCorrection(medId, selectedMed, loadMeds); + if (!stockCorrectionMed) return; + await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds); }; // For MedDetailModal: refill without form update (not editing) @@ -367,11 +374,19 @@ function AppContent() { await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription); }; - // Wrapper for openEditStockModal (provides selectedMed and coverage) - const handleOpenEditStockModal = () => { - if (selectedMed) { - openEditStockModal(selectedMed, coverage); - } + const handleOpenMedicationEdit = () => { + if (!selectedMed) return; + const medId = selectedMed.id; + setShowImageLightbox(false); + setShowRefillModal(false); + setShowEditStockModal(false); + setSelectedMed(null); + navigate(`/medications?editMedId=${medId}`); + }; + + const handleOpenEditStockFromDetail = () => { + if (!selectedMed) return; + openEditStockModal(selectedMed, coverage); }; function openProfile() { @@ -421,18 +436,20 @@ function AppContent() { {/* Medication Detail Modal */} diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index 2a4f9c2..f12499c 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -12,7 +12,7 @@ export interface ConfirmModalProps { onConfirm: () => void; onCancel: () => void; isLoading?: boolean; - confirmVariant?: "primary" | "danger" | "success"; + confirmVariant?: "primary" | "danger" | "success" | "warning"; overlayClassName?: string; } diff --git a/frontend/src/components/Lightbox.tsx b/frontend/src/components/Lightbox.tsx index 050da53..b2a54a4 100644 --- a/frontend/src/components/Lightbox.tsx +++ b/frontend/src/components/Lightbox.tsx @@ -3,6 +3,7 @@ // ============================================================================= import type { MouseEvent } from "react"; +import { useEffect } from "react"; export interface LightboxProps { src: string; @@ -11,6 +12,17 @@ export interface LightboxProps { } export function Lightbox({ src, alt, onClose }: LightboxProps) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + function handleOverlayClick(e: MouseEvent) { e.stopPropagation(); if (e.target === e.currentTarget) { @@ -19,13 +31,7 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) { } return ( -
{ - if (e.key === "Escape") onClose(); - }} - > +
+ onChange(e.target.value)} + onBlur={onBlur} + /> + +
+ ); + }; + + const renderRefillStepperInput = ({ + value, + min, + max, + onChange, + }: { + value: number; + min: number; + max: number; + onChange: (next: number) => void; + }) => { + const clamped = Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); + const canDecrement = clamped > min; + const canIncrement = clamped < max; + + return ( +
+ + { + const parsed = Number.parseInt(e.target.value, 10); + onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed))); + }} + /> + +
+ ); + }; + + const renderEditStockModal = () => { + if (!showEditStockModal) return null; + const fullInputMax = Math.min( + maxFullBlisters, + Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister) + ); + + return ( +
{ + e.stopPropagation(); + onCloseEditStockModal(); + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") onCloseEditStockModal(); + }} + > +
e.stopPropagation()} + onKeyDownCapture={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onCloseEditStockModal(); + } + }} + onKeyDown={(e) => e.stopPropagation()} + > + +

{t("editStock.title")}

+

{selectedMed.name}

+

{t("editStock.hint")}

+ {selectedMed.packageType === "blister" && ( +

+ {t("editStock.currentComposition", { + fullBlisters: currentFullBlisters, + partialPills: currentPartialPills, + loosePills: currentLoosePills, + total: Math.max(0, currentStock), + })} +

+ )} + {selectedMed.packageType === "bottle" && ( +

{t("editStock.packageSize", { count: structuralMax })}

+ )} + {showStockCapNotice && ( +

{t("editStock.maxExceeded", { count: structuralMax })}

+ )} + + {(() => { + const dbTotal = getMedTotal(selectedMed); + const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal; + const isBottle = selectedMed.packageType === "bottle"; + const enteredTotal = isBottle + ? editStockPartialBlisterPills + : editStockFullBlisters * selectedMed.pillsPerBlister + + editStockPartialBlisterPills + + editStockLoosePills; + const newTotal = Math.max(0, enteredTotal); + const difference = newTotal - currentTotal; + const differenceClass = difference > 0 ? "positive" : difference < 0 ? "negative" : ""; + + return ( + <> +
+ {isBottle ? ( + + ) : ( + <> + + + + + )} +
+ +
+
+ {t("editStock.currentTotal")}: + + {currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")} + +
+
+ {t("editStock.newTotal")}: + + {newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")} + +
+
+ {t("editStock.difference")}: + + {difference > 0 ? "+" : ""} + {difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")} + +
+
+ + ); + })()} + +
+ + +
+
+
+ ); + }; + + if (editStockOnly) { + return renderEditStockModal(); + } return (
{ + if (showEditStockModal) return; if (e.key === "Escape") onClose(); }} >
e.stopPropagation()} + onKeyDownCapture={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }} onKeyDown={(e) => e.stopPropagation()} > -
@@ -237,10 +756,8 @@ export function MedDetailModal({
{t("modal.currentStock")} - {currentStock} /{" "} - {selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize} - {currentStock > - (selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize) && ( + {currentStock} / {stockDisplayTotal} + {currentStock > stockDisplayTotal && (
{t("modal.packs")} - {selectedMed.packCount} + {remainingPacks}
{t("modal.blistersPerPack")} @@ -350,7 +867,7 @@ export function MedDetailModal({ {t("modal.intakeSchedule")}{" "} {selectedMed.intakeRemindersEnabled && ( - 🔔 + )} @@ -412,7 +929,12 @@ export function MedDetailModal({ {/* Notes Section */} {selectedMed.notes && (
-

📝 {t("modal.notes")}

+

+ {" "} + {t("modal.notes")} +

{selectedMed.notes}
)} @@ -458,7 +980,7 @@ export function MedDetailModal({ {entry.usedPrescription && ( {" "} - 📋 + )} @@ -468,27 +990,44 @@ export function MedDetailModal({ )}
)} -
- - {/* Footer */} -
- -
- - - {selectedMed.blisters.length > 0 && ( - +
+ - )} + {onOpenMedicationEdit && ( + + )} + {onOpenEditStockModal && ( + + )} + {selectedMed.blisters.length > 0 && ( + + )} +
@@ -516,8 +1055,14 @@ export function MedDetailModal({ onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > -

{t("refill.title")}

{selectedMed.name}

@@ -527,32 +1072,32 @@ export function MedDetailModal({ <> ) : ( )} @@ -562,7 +1107,17 @@ export function MedDetailModal({ onUsePrescriptionRefillChange(e.target.checked)} + onChange={(e) => { + const checked = e.target.checked; + onUsePrescriptionRefillChange(checked); + if ( + checked && + selectedMed.packageType === "blister" && + refillPacks > remainingPrescriptionRefills + ) { + onRefillPacksChange(remainingPrescriptionRefills); + } + }} disabled={(Number(selectedMed.prescriptionRemainingRefills) || 0) <= 0} /> {t("prescription.useForRefill")} @@ -582,14 +1137,20 @@ export function MedDetailModal({ {(() => { const totalRefill = selectedMed.packageType === "blister" - ? refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose + ? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose : refillLoose; return totalRefill > 0 ? ( @@ -604,156 +1165,7 @@ export function MedDetailModal({ )} {/* Edit Stock Modal */} - {showEditStockModal && ( -
{ - e.stopPropagation(); - onCloseEditStockModal(); - }} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Escape") onCloseEditStockModal(); - }} - > -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - -

{t("editStock.title")}

-

{selectedMed.name}

-

{t("editStock.hint")}

- - {(() => { - const dbTotal = getMedTotal(selectedMed); - const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal; - const isBottle = selectedMed.packageType === "bottle"; - const newTotal = isBottle - ? editStockPartialBlisterPills - : editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills; - const difference = newTotal - currentTotal; - const negativeFallback = difference < 0 ? "negative" : ""; - const differenceClass = difference > 0 ? "positive" : negativeFallback; - - return ( - <> -
- {isBottle ? ( - - ) : ( - <> - - - - )} -
- -
-
- {t("editStock.currentTotal")}: - - {currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")} - -
-
- {t("editStock.newTotal")}: - - {newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")} - -
-
- {t("editStock.difference")}: - - {difference > 0 ? "+" : ""} - {difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")} - -
-
- - ); - })()} - -
- - -
-
-
- )} + {renderEditStockModal()}
); } diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index 4fe21f4..ce49313 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -2,7 +2,9 @@ * MobileEditModal - Full-screen edit form for medications (mobile-optimized) * Handles new medication creation and editing existing medications */ -import { useEffect } from "react"; + +import { Minus, Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types"; import { DOSE_UNITS } from "../types"; @@ -46,15 +48,6 @@ export interface MobileEditModalProps { onRemoveIntake: (idx: number) => void; // Value change handler for numeric fields onHandleValueChange: (field: K, value: FormState[K]) => void; - // Refill state (for edit mode) - refillPacks: number; - onRefillPacksChange: (value: number) => void; - refillLoose: number; - onRefillLooseChange: (value: number) => void; - usePrescriptionRefill: boolean; - onUsePrescriptionRefillChange: (value: boolean) => void; - refillSaving: boolean; - onSubmitRefill: (medId: number) => Promise; // Image handling meds: Medication[]; onUploadMedImage: (medId: number, file: File) => Promise; @@ -74,8 +67,7 @@ function deriveTotalFromForm(form: FormState) { const packCount = Number(form.packCount) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0; const pillsPerBlister = Number(form.pillsPerBlister) || 1; - const looseTablets = Number(form.looseTablets) || 0; - return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets); + return deriveTotal(packCount, blistersPerPack, pillsPerBlister, 0); } export function MobileEditModal({ @@ -103,14 +95,6 @@ export function MobileEditModal({ onAddIntake, onRemoveIntake, onHandleValueChange, - refillPacks, - onRefillPacksChange, - refillLoose, - onRefillLooseChange, - usePrescriptionRefill, - onUsePrescriptionRefillChange, - refillSaving, - onSubmitRefill, meds, onUploadMedImage, onDeleteMedImage, @@ -119,6 +103,12 @@ export function MobileEditModal({ onSaveMedication, }: MobileEditModalProps) { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general"); + + // Reset tab when modal opens + useEffect(() => { + if (show) setActiveTab("general"); + }, [show]); // Close on Escape key useEffect(() => { @@ -162,6 +152,10 @@ export function MobileEditModal({
{ // Check native HTML5 validation first const formElement = e.currentTarget; @@ -174,463 +168,434 @@ export function MobileEditModal({ onSaveMedication(e); }} > +
+ + + + +
-
-

{t("form.sections.general")}

- - - -