/** * E2E Tests using the real routes against in-memory SQLite. * These tests import the actual route handlers for real coverage. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; import Fastify, { FastifyInstance } from "fastify"; import cookie from "@fastify/cookie"; import jwt from "@fastify/jwt"; import sensible from "@fastify/sensible"; import fastifyMultipart from "@fastify/multipart"; import { createClient, Client } from "@libsql/client"; import { drizzle, LibSQLDatabase } from "drizzle-orm/libsql"; // Use vi.hoisted to create the db BEFORE mocks are set up const { testClient, testDb } = vi.hoisted(() => { // Dynamic import inside hoisted block const { createClient } = require("@libsql/client"); const { drizzle } = require("drizzle-orm/libsql"); const client = createClient({ url: ":memory:" }); const db = drizzle(client); return { testClient: client, testDb: db }; }); // Mock modules using the hoisted db vi.mock("../db/client.js", () => ({ db: testDb, migrationsReady: Promise.resolve(), })); vi.mock("../plugins/env.js", () => ({ env: { AUTH_ENABLED: false, NODE_ENV: "test", LOG_LEVEL: "silent", PORT: 3000, CORS_ORIGINS: "*", JWT_SECRET: "test-secret", REFRESH_SECRET: "test-refresh-secret", COOKIE_SECRET: "test-cookie-secret", ACCESS_TOKEN_TTL_MINUTES: 15, REFRESH_TOKEN_TTL_DAYS: 7, }, })); // Mock auth plugin vi.mock("../plugins/auth.js", () => ({ requireAuth: async () => {}, getAnonymousUserId: () => 999999999, })); // Now import routes AFTER mocking const { doseRoutes } = await import("../routes/doses.js"); const { shareRoutes } = await import("../routes/share.js"); const { medicationRoutes } = await import("../routes/medications.js"); const { settingsRoutes } = await import("../routes/settings.js"); const { healthRoutes } = await import("../routes/health.js"); // ============================================================================= // Test Setup // ============================================================================= async function createSchema(client: Client) { const tableCreations = [ `CREATE TABLE IF NOT EXISTS users ( id integer PRIMARY KEY AUTOINCREMENT, username text NOT NULL UNIQUE, password_hash text, avatar_url text, auth_provider text NOT NULL DEFAULT 'local', oidc_subject text, is_active integer NOT NULL DEFAULT 1, last_login_at integer, created_at integer NOT NULL DEFAULT (strftime('%s','now')), updated_at integer NOT NULL DEFAULT (strftime('%s','now')) )`, `CREATE TABLE IF NOT EXISTS medications ( id integer PRIMARY KEY AUTOINCREMENT, user_id integer NOT NULL, name text NOT NULL, generic_name text, taken_by_json text NOT NULL DEFAULT '[]', pack_count integer NOT NULL DEFAULT 1, blisters_per_pack integer NOT NULL DEFAULT 1, pills_per_blister integer NOT NULL DEFAULT 1, loose_tablets integer NOT NULL DEFAULT 0, pill_weight_mg integer, usage_json text NOT NULL DEFAULT '[]', every_json text NOT NULL DEFAULT '[]', start_json text NOT NULL DEFAULT '[]', image_url text, expiry_date text, notes text, intake_reminders_enabled integer NOT NULL DEFAULT 0, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS user_settings ( id integer PRIMARY KEY AUTOINCREMENT, user_id integer NOT NULL UNIQUE, email_enabled integer NOT NULL DEFAULT 0, notification_email text, email_stock_reminders integer NOT NULL DEFAULT 1, email_intake_reminders integer NOT NULL DEFAULT 1, shoutrrr_enabled integer NOT NULL DEFAULT 0, shoutrrr_url text, shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, low_stock_days integer NOT NULL DEFAULT 30, normal_stock_days integer NOT NULL DEFAULT 90, high_stock_days integer NOT NULL DEFAULT 180, expiry_warning_days integer NOT NULL DEFAULT 90, language text NOT NULL DEFAULT 'en', stock_calculation_mode text NOT NULL DEFAULT 'automatic', last_auto_email_sent text, last_notification_type text, last_notification_channel text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS share_tokens ( id integer PRIMARY KEY AUTOINCREMENT, user_id integer NOT NULL, token text NOT NULL UNIQUE, taken_by text NOT NULL, schedule_days integer NOT NULL DEFAULT 30, created_at integer NOT NULL DEFAULT (strftime('%s','now')), expires_at integer, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS dose_tracking ( id integer PRIMARY KEY AUTOINCREMENT, user_id integer NOT NULL, dose_id text NOT NULL, taken_at integer NOT NULL DEFAULT (strftime('%s','now')), marked_by text, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, ]; for (const sql of tableCreations) { await client.execute(sql); } } async function clearData(client: Client) { await client.execute("DELETE FROM dose_tracking"); await client.execute("DELETE FROM share_tokens"); await client.execute("DELETE FROM user_settings"); await client.execute("DELETE FROM medications"); await client.execute("DELETE FROM users"); await client.execute("DELETE FROM sqlite_sequence"); } async function createUser(client: Client, username: string): Promise { const result = await client.execute({ sql: `INSERT INTO users (username, auth_provider) VALUES (?, 'local') RETURNING id`, args: [username], }); return result.rows[0].id as number; } async function createMedication( client: Client, userId: number, name: string, takenBy: string[] ): Promise { const result = await client.execute({ sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json) VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`, args: [userId, name, JSON.stringify(takenBy)], }); return result.rows[0].id as number; } async function createShareToken( client: Client, userId: number, takenBy: string, token: string ): Promise { await client.execute({ sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)`, args: [userId, token, takenBy], }); } // ============================================================================= // E2E Tests with Real Routes // ============================================================================= describe("E2E Tests with Real Routes", () => { let app: FastifyInstance; let userId: number; beforeAll(async () => { // Create schema await createSchema(testClient); // Build app with real routes app = Fastify({ logger: false }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret" }); await app.register(jwt, { secret: "test-jwt-secret", cookie: { cookieName: "access_token", signed: false }, }); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); app.decorate("config", { accessSecret: "test-jwt-secret", refreshSecret: "test-refresh-secret", accessTtl: 15, refreshTtl: 7, cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" }, }); // Register REAL routes await app.register(doseRoutes); await app.register(shareRoutes); await app.register(medicationRoutes); await app.register(settingsRoutes); await app.register(healthRoutes); await app.ready(); }); afterAll(async () => { await app.close(); testClient.close(); }); beforeEach(async () => { await clearData(testClient); // Create anonymous user with fixed ID for auth-disabled mode await testClient.execute( "INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')" ); userId = 999999999; }); // --------------------------------------------------------------------------- // Real Dose Routes Tests // --------------------------------------------------------------------------- describe("Real /doses/taken routes", () => { it("should mark a dose using real route", async () => { const doseId = "1-0-1735344000000"; const response = await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); // Verify in database const result = await testClient.execute({ sql: `SELECT dose_id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, args: [userId, doseId], }); expect(result.rows.length).toBe(1); }); it("should get taken doses using real route", async () => { // Insert dose directly await testClient.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id) VALUES (?, ?)`, args: [userId, "1-0-1735344000000"], }); const response = await app.inject({ method: "GET", url: "/doses/taken", }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.doses).toHaveLength(1); expect(data.doses[0].doseId).toBe("1-0-1735344000000"); }); it("should delete dose using real route", async () => { const doseId = "1-0-1735344000000"; // Insert first await testClient.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id) VALUES (?, ?)`, args: [userId, doseId], }); const response = await app.inject({ method: "DELETE", url: `/doses/taken/${encodeURIComponent(doseId)}`, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); // Verify deleted const result = await testClient.execute({ sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, args: [doseId], }); expect(result.rows[0].count).toBe(0); }); }); // --------------------------------------------------------------------------- // Real Share Routes Tests // --------------------------------------------------------------------------- describe("Real /share routes", () => { it("should create share token using real route", async () => { // Create medication with takenBy await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const response = await app.inject({ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 30 }, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.token).toBeDefined(); expect(data.shareUrl).toContain("/share/"); }); it("should get shared schedule using real route", async () => { // Create medication await createMedication(testClient, userId, "Aspirin", ["Daniel"]); // Create share token const token = "test_share_token_123"; await createShareToken(testClient, userId, "Daniel", token); const response = await app.inject({ method: "GET", url: `/share/${token}`, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.takenBy).toBe("Daniel"); expect(data.medications).toHaveLength(1); expect(data.medications[0].name).toBe("Aspirin"); }); it("should mark dose via share link using real route", async () => { await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "test_share_token_456"; await createShareToken(testClient, userId, "Daniel", token); const doseId = "1-0-1735344000000"; const response = await app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); // Verify markedBy is set const result = await testClient.execute({ sql: `SELECT marked_by FROM dose_tracking WHERE dose_id = ?`, args: [doseId], }); expect(result.rows[0].marked_by).toBe("Daniel"); }); it("should return 404 for invalid share token", async () => { const response = await app.inject({ method: "GET", url: "/share/invalid_token", }); expect(response.statusCode).toBe(404); }); }); // --------------------------------------------------------------------------- // Real Medication Routes Tests // --------------------------------------------------------------------------- describe("Real /medications routes", () => { const validMedication = { name: "Aspirin", genericName: "Acetylsalicylic acid", takenBy: ["Daniel"], packCount: 2, blistersPerPack: 3, pillsPerBlister: 10, looseTablets: 5, pillWeightMg: 500, expiryDate: "2026-12-31", notes: "Take with food", intakeRemindersEnabled: true, blisters: [ { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, ], }; it("should create medication using real route", async () => { const response = await app.inject({ method: "POST", url: "/medications", payload: validMedication, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.id).toBeDefined(); expect(data.name).toBe("Aspirin"); expect(data.genericName).toBe("Acetylsalicylic acid"); expect(data.takenBy).toEqual(["Daniel"]); expect(data.packCount).toBe(2); expect(data.blistersPerPack).toBe(3); expect(data.pillsPerBlister).toBe(10); expect(data.looseTablets).toBe(5); expect(data.pillWeightMg).toBe(500); expect(data.blisters).toHaveLength(1); }); it("should list medications using real route", async () => { // Create medication first await app.inject({ method: "POST", url: "/medications", payload: validMedication, }); const response = await app.inject({ method: "GET", url: "/medications", }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data).toHaveLength(1); expect(data[0].name).toBe("Aspirin"); }); it("should update medication using real route", async () => { // Create medication first const createResponse = await app.inject({ method: "POST", url: "/medications", payload: validMedication, }); const medId = createResponse.json().id; // Update it const updatedMed = { ...validMedication, name: "Aspirin Extra", looseTablets: 10, }; const response = await app.inject({ method: "PUT", url: `/medications/${medId}`, payload: updatedMed, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.name).toBe("Aspirin Extra"); expect(data.looseTablets).toBe(10); }); it("should delete medication using real route", async () => { // Create medication first const createResponse = await app.inject({ method: "POST", url: "/medications", payload: validMedication, }); const medId = createResponse.json().id; // Delete it const response = await app.inject({ method: "DELETE", url: `/medications/${medId}`, }); expect(response.statusCode).toBe(204); // Verify deleted const listResponse = await app.inject({ method: "GET", url: "/medications", }); expect(listResponse.json()).toHaveLength(0); }); it("should return 400 for invalid medication data", async () => { const response = await app.inject({ method: "POST", url: "/medications", payload: { name: "" }, // Invalid - empty name and no blisters }); expect(response.statusCode).toBe(400); }); it("should return 404 for non-existent medication", async () => { const response = await app.inject({ method: "PUT", url: "/medications/99999", payload: validMedication, }); expect(response.statusCode).toBe(404); }); it("should create medication with multiple intake schedules", async () => { const multiBlisterMed = { ...validMedication, blisters: [ { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, { usage: 0.5, every: 1, start: "2025-01-01T20:00:00.000Z" }, ], }; const response = await app.inject({ method: "POST", url: "/medications", payload: multiBlisterMed, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.blisters).toHaveLength(2); expect(data.blisters[0].usage).toBe(1); expect(data.blisters[1].usage).toBe(0.5); }); }); // --------------------------------------------------------------------------- // Real Settings Routes Tests // --------------------------------------------------------------------------- describe("Real /settings routes", () => { it("should get default settings using real route", async () => { const response = await app.inject({ method: "GET", url: "/settings", }); expect(response.statusCode).toBe(200); const data = response.json(); // Check default values expect(data.emailEnabled).toBe(false); expect(data.lowStockDays).toBe(30); expect(data.normalStockDays).toBe(90); expect(data.highStockDays).toBe(180); expect(data.language).toBe("en"); expect(data.stockCalculationMode).toBe("automatic"); }); it("should update settings using real route", async () => { const newSettings = { emailEnabled: true, notificationEmail: "test@example.com", reminderDaysBefore: 14, repeatDailyReminders: false, lowStockDays: 14, normalStockDays: 60, highStockDays: 120, shoutrrrEnabled: false, shoutrrrUrl: "", emailStockReminders: true, emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, language: "de", stockCalculationMode: "manual", }; const response = await app.inject({ method: "PUT", url: "/settings", payload: newSettings, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); // Verify settings were saved const getResponse = await app.inject({ method: "GET", url: "/settings", }); const data = getResponse.json(); expect(data.emailEnabled).toBe(true); expect(data.notificationEmail).toBe("test@example.com"); expect(data.lowStockDays).toBe(14); expect(data.language).toBe("de"); expect(data.stockCalculationMode).toBe("manual"); }); it("should update existing settings using real route", async () => { // First update await app.inject({ method: "PUT", url: "/settings", payload: { emailEnabled: true, notificationEmail: "first@example.com", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, shoutrrrEnabled: false, shoutrrrUrl: "", emailStockReminders: true, emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, language: "en", stockCalculationMode: "automatic", }, }); // Second update const response = await app.inject({ method: "PUT", url: "/settings", payload: { emailEnabled: false, notificationEmail: "second@example.com", reminderDaysBefore: 14, repeatDailyReminders: true, lowStockDays: 20, normalStockDays: 60, highStockDays: 120, shoutrrrEnabled: true, shoutrrrUrl: "ntfy://localhost/alerts", emailStockReminders: false, emailIntakeReminders: false, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, language: "de", stockCalculationMode: "manual", }, }); expect(response.statusCode).toBe(200); // Verify updated const getResponse = await app.inject({ method: "GET", url: "/settings", }); const data = getResponse.json(); expect(data.emailEnabled).toBe(false); expect(data.notificationEmail).toBe("second@example.com"); expect(data.shoutrrrEnabled).toBe(true); expect(data.shoutrrrUrl).toBe("ntfy://localhost/alerts"); expect(data.stockCalculationMode).toBe("manual"); }); it("should disable repeatDailyReminders when no stock reminders configured", async () => { const response = await app.inject({ method: "PUT", url: "/settings", payload: { emailEnabled: false, // No email notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: true, // User tries to enable lowStockDays: 30, normalStockDays: 90, highStockDays: 180, shoutrrrEnabled: false, // No shoutrrr shoutrrrUrl: "", emailStockReminders: true, emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, language: "en", stockCalculationMode: "automatic", }, }); expect(response.statusCode).toBe(200); // Verify repeatDailyReminders is false const getResponse = await app.inject({ method: "GET", url: "/settings", }); const data = getResponse.json(); expect(data.repeatDailyReminders).toBe(false); }); }); // --------------------------------------------------------------------------- // Health Route Tests // --------------------------------------------------------------------------- describe("Real /health route", () => { it("should return health status", async () => { const response = await app.inject({ method: "GET", url: "/health", }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ status: "ok" }); }); }); // --------------------------------------------------------------------------- // Additional Share Routes Tests (edge cases) // --------------------------------------------------------------------------- describe("Real /share routes - edge cases", () => { it("should get list of people with medications", async () => { // Create medications for different people await createMedication(testClient, userId, "Aspirin", ["Daniel"]); await createMedication(testClient, userId, "Ibuprofen", ["Anna"]); await createMedication(testClient, userId, "Paracetamol", ["Daniel", "Anna"]); const response = await app.inject({ method: "GET", url: "/share/people", }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.people).toContain("Daniel"); expect(data.people).toContain("Anna"); expect(data.people).toHaveLength(2); }); it("should return error when creating share for person with no meds", async () => { // Create medication for a different person await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const response = await app.inject({ method: "POST", url: "/share", payload: { takenBy: "Unknown", scheduleDays: 30 }, }); expect(response.statusCode).toBe(400); expect(response.json().code).toBe("NO_MEDICATIONS"); }); it("should unmark dose via share link", async () => { await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "test_delete_dose_token"; await createShareToken(testClient, userId, "Daniel", token); // First mark the dose const doseId = "1-0-1735344000000"; await testClient.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, args: [userId, doseId, "Daniel"], }); // Now unmark via share link const response = await app.inject({ method: "DELETE", url: `/share/${token}/doses/${encodeURIComponent(doseId)}`, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); // Verify deleted const result = await testClient.execute({ sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`, args: [doseId], }); expect(result.rows[0].count).toBe(0); }); it("should return 410 for expired share token", async () => { await createMedication(testClient, userId, "Aspirin", ["Daniel"]); // Create expired token const token = "expired_token_123"; const expiredAt = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago await testClient.execute({ sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, 30, ?)`, args: [userId, token, "Daniel", expiredAt], }); const response = await app.inject({ method: "GET", url: `/share/${token}`, }); expect(response.statusCode).toBe(410); expect(response.json().code).toBe("EXPIRED"); }); it("should return already marked message for duplicate dose", async () => { await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "test_duplicate_token"; await createShareToken(testClient, userId, "Daniel", token); const doseId = "1-0-1735344000000"; // Mark the dose first time await app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId }, }); // Try to mark again const response = await app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json().message).toBe("Already marked"); }); }); // --------------------------------------------------------------------------- // Additional Dose Routes Tests (edge cases) // --------------------------------------------------------------------------- describe("Real /doses/taken routes - edge cases", () => { it("should return already marked message for duplicate dose", async () => { const doseId = "1-0-1735344000000"; // Mark first time await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId }, }); // Mark second time const response = await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json().message).toBe("Already marked"); }); it("should handle doses with person name in doseId", async () => { const doseId = "1-0-1735344000000-Daniel"; const response = await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); // Verify in database const result = await testClient.execute({ sql: `SELECT dose_id FROM dose_tracking WHERE dose_id = ?`, args: [doseId], }); expect(result.rows.length).toBe(1); }); }); // --------------------------------------------------------------------------- // Additional Medication Routes Tests (edge cases) // --------------------------------------------------------------------------- describe("Real /medications routes - edge cases", () => { const validMedication = { name: "Aspirin", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }; it("should return 404 when deleting non-existent medication", async () => { const response = await app.inject({ method: "DELETE", url: "/medications/99999", }); expect(response.statusCode).toBe(404); }); it("should handle medication with all optional fields", async () => { const fullMedication = { name: "Complete Med", genericName: "Generic Complete", takenBy: ["Person1", "Person2"], packCount: 5, blistersPerPack: 4, pillsPerBlister: 20, looseTablets: 10, pillWeightMg: 250, expiryDate: "2026-06-30", notes: "Some important notes about this medication", intakeRemindersEnabled: true, blisters: [ { usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }, { usage: 1, every: 1, start: "2025-01-01T20:00:00.000Z" }, ], }; const response = await app.inject({ method: "POST", url: "/medications", payload: fullMedication, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.genericName).toBe("Generic Complete"); expect(data.takenBy).toEqual(["Person1", "Person2"]); expect(data.packCount).toBe(5); expect(data.blistersPerPack).toBe(4); expect(data.pillsPerBlister).toBe(20); expect(data.looseTablets).toBe(10); expect(data.pillWeightMg).toBe(250); expect(data.expiryDate).toBe("2026-06-30"); expect(data.notes).toBe("Some important notes about this medication"); expect(data.intakeRemindersEnabled).toBe(true); expect(data.blisters).toHaveLength(2); }); it("should update medication with partial fields", async () => { // Create medication first const createResponse = await app.inject({ method: "POST", url: "/medications", payload: { name: "Original Med", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }] }, }); const medId = createResponse.json().id; // Update with partial fields const response = await app.inject({ method: "PUT", url: `/medications/${medId}`, payload: { name: "Updated Med", genericName: "New Generic", notes: "Updated notes", blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, }); expect(response.statusCode).toBe(200); expect(response.json().name).toBe("Updated Med"); expect(response.json().genericName).toBe("New Generic"); expect(response.json().notes).toBe("Updated notes"); }); it("should handle string takenBy conversion", async () => { // Test with takenBy as array (expected format) const response = await app.inject({ method: "POST", url: "/medications", payload: { name: "Array TakenBy Med", takenBy: ["SinglePerson"], blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, }); expect(response.statusCode).toBe(200); expect(response.json().takenBy).toEqual(["SinglePerson"]); }); }); // --------------------------------------------------------------------------- // Test Email/Shoutrrr Validation (settings.ts - uncovered paths) // --------------------------------------------------------------------------- describe("Real /settings test routes", () => { it("should reject test-email when SMTP is not configured", async () => { const response = await app.inject({ method: "POST", url: "/settings/test-email", payload: { email: "test@example.com" }, }); expect(response.statusCode).toBe(400); expect(response.json().error).toBe("SMTP not configured"); }); it("should reject test-shoutrrr without URL", async () => { const response = await app.inject({ method: "POST", url: "/settings/test-shoutrrr", payload: { url: "" }, }); expect(response.statusCode).toBe(400); expect(response.json().error).toBe("Notification URL is required"); }); it("should reject test-shoutrrr with unsupported URL format", async () => { const response = await app.inject({ method: "POST", url: "/settings/test-shoutrrr", payload: { url: "ftp://invalid.com/topic" }, }); expect(response.statusCode).toBe(500); // SSRF protection returns more specific error message expect(response.json().error).toContain("HTTP/HTTPS protocols"); }); it("should reject test-shoutrrr with localhost URL (SSRF protection)", async () => { const response = await app.inject({ method: "POST", url: "/settings/test-shoutrrr", payload: { url: "https://localhost/topic" }, }); expect(response.statusCode).toBe(500); expect(response.json().error).toContain("Localhost URLs are not allowed"); }); it("should reject test-shoutrrr with private IP (SSRF protection)", async () => { const response = await app.inject({ method: "POST", url: "/settings/test-shoutrrr", payload: { url: "https://192.168.1.1/topic" }, }); expect(response.statusCode).toBe(500); expect(response.json().error).toContain("Private IP addresses are not allowed"); }); it("should reject test-shoutrrr with internal hostname (SSRF protection)", async () => { const response = await app.inject({ method: "POST", url: "/settings/test-shoutrrr", payload: { url: "https://server.internal/topic" }, }); expect(response.statusCode).toBe(500); expect(response.json().error).toContain("Internal hostnames are not allowed"); }); }); // --------------------------------------------------------------------------- // Additional Doses Routes Tests // --------------------------------------------------------------------------- describe("Real /doses routes - more coverage", () => { it("should return 400 when doseId is missing", async () => { const response = await app.inject({ method: "POST", url: "/doses/taken", payload: {}, }); expect(response.statusCode).toBe(400); }); it("should handle dose marking and get taken doses", async () => { const doseId = "99-0-1735344000099"; // Mark the dose const markResponse = await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId }, }); expect(markResponse.statusCode).toBe(200); expect(markResponse.json()).toEqual({ success: true }); // The GET returns doses for current user (anonymous in test) // Each beforeEach clears data, so we just verify POST works correctly }); it("should handle cleaning old doses for future date range", async () => { // Create a medication first const createResponse = await app.inject({ method: "POST", url: "/medications", payload: { name: "CleanTest Med", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, }); const medId = createResponse.json().id; // Mark some doses await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-1735344000000` }, }); // Update medication with new start date await app.inject({ method: "PUT", url: `/medications/${medId}`, payload: { name: "CleanTest Med", blisters: [{ usage: 1, every: 1, start: "2025-06-01T08:00:00.000Z" }], // Future start }, }); // The dose tracking for the old period should be cleaned up // This is handled by the medications route internally }); }); // --------------------------------------------------------------------------- // Health Check Tests // --------------------------------------------------------------------------- describe("Real /health routes", () => { it("should return health status", async () => { const response = await app.inject({ method: "GET", url: "/health", }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ status: "ok" }); }); }); // --------------------------------------------------------------------------- // Medication Delete Cascade Tests // --------------------------------------------------------------------------- describe("Medication deletion with dose tracking", () => { it("should handle medication deletion that has tracked doses", async () => { // Create medication const createResponse = await app.inject({ method: "POST", url: "/medications", payload: { name: "Test Med", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, }); const medId = createResponse.json().id; // Mark a dose for this medication await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-1735344000000` }, }); // Delete medication - should succeed even with tracked doses const deleteResponse = await app.inject({ method: "DELETE", url: `/medications/${medId}`, }); expect(deleteResponse.statusCode).toBe(204); }); }); // --------------------------------------------------------------------------- // Settings Edge Cases // --------------------------------------------------------------------------- describe("Settings edge cases", () => { it("should handle settings with all reminder options enabled", async () => { const response = await app.inject({ method: "PUT", url: "/settings", payload: { emailEnabled: true, notificationEmail: "test@example.com", reminderDaysBefore: 7, repeatDailyReminders: true, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, shoutrrrEnabled: true, shoutrrrUrl: "ntfy://localhost/test", emailStockReminders: true, emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, language: "en", stockCalculationMode: "automatic", }, }); expect(response.statusCode).toBe(200); // Verify repeatDailyReminders is preserved when notifications are enabled const getResponse = await app.inject({ method: "GET", url: "/settings", }); const data = getResponse.json(); expect(data.repeatDailyReminders).toBe(true); expect(data.emailEnabled).toBe(true); expect(data.shoutrrrEnabled).toBe(true); }); it("should handle expiry warning days setting", async () => { const response = await app.inject({ method: "PUT", url: "/settings", payload: { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 14, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, expiryWarningDays: 60, shoutrrrEnabled: false, shoutrrrUrl: "", emailStockReminders: true, emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, language: "en", stockCalculationMode: "automatic", }, }); expect(response.statusCode).toBe(200); }); }); // --------------------------------------------------------------------------- // Share Token Management // --------------------------------------------------------------------------- describe("Share token management", () => { it("should create share token with custom scheduleDays", async () => { await createMedication(testClient, userId, "Med1", ["Daniel"]); const response = await app.inject({ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 90, }, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.token).toBeDefined(); expect(data.expiresAt).toBeDefined(); }); it("should return validation error for invalid scheduleDays", async () => { await createMedication(testClient, userId, "Med1", ["Daniel"]); const response = await app.inject({ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 500, // Too high, max is 365 }, }); expect(response.statusCode).toBe(400); }); it("should return validation error for missing takenBy", async () => { const response = await app.inject({ method: "POST", url: "/share", payload: { scheduleDays: 30, }, }); expect(response.statusCode).toBe(400); expect(response.json().code).toBe("VALIDATION_ERROR"); }); it("should get people list with multiple persons", async () => { await createMedication(testClient, userId, "Med1", ["Daniel"]); await createMedication(testClient, userId, "Med2", ["Anna"]); await createMedication(testClient, userId, "Med3", ["Daniel", "Anna"]); const response = await app.inject({ method: "GET", url: "/share/people", }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.people).toContain("Daniel"); expect(data.people).toContain("Anna"); }); }); // --------------------------------------------------------------------------- // Dose validation tests // --------------------------------------------------------------------------- describe("Dose validation", () => { it("should reject invalid doseId format in POST", async () => { const response = await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: null }, }); expect(response.statusCode).toBe(400); }); it("should handle empty string doseId", async () => { const response = await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: "" }, }); expect(response.statusCode).toBe(400); }); }); // --------------------------------------------------------------------------- // Medication validation edge cases // --------------------------------------------------------------------------- describe("Medication validation edge cases", () => { it("should reject medication without blisters", async () => { const response = await app.inject({ method: "POST", url: "/medications", payload: { name: "No Blisters Med" }, }); expect(response.statusCode).toBe(400); }); it("should reject medication with empty blisters array", async () => { const response = await app.inject({ method: "POST", url: "/medications", payload: { name: "Empty Blisters Med", blisters: [] }, }); expect(response.statusCode).toBe(400); }); it("should reject medication with invalid blister data", async () => { const response = await app.inject({ method: "POST", url: "/medications", payload: { name: "Invalid Blister Med", blisters: [{ usage: -1, every: 0, start: "invalid-date" }], }, }); expect(response.statusCode).toBe(400); }); it("should handle medication with minimal valid data", async () => { const response = await app.inject({ method: "POST", url: "/medications", payload: { name: "Minimal Med", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.name).toBe("Minimal Med"); // Check defaults expect(data.packCount).toBe(1); expect(data.blistersPerPack).toBe(1); expect(data.pillsPerBlister).toBe(1); expect(data.looseTablets).toBe(0); expect(data.takenBy).toEqual([]); }); }); // --------------------------------------------------------------------------- // Share Token Dose Routes (via share link) // --------------------------------------------------------------------------- describe("Share token dose routes", () => { it("should get taken doses via share link", async () => { await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "get-doses-token"; await createShareToken(testClient, userId, "Daniel", token); // Insert a dose directly await testClient.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, args: [userId, "1-0-1735344000000", "Daniel"], }); const response = await app.inject({ method: "GET", url: `/share/${token}/doses`, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.doses).toHaveLength(1); expect(data.doses[0].doseId).toBe("1-0-1735344000000"); expect(data.doses[0].markedBy).toBe("Daniel"); }); it("should return 404 for get doses with invalid share token", async () => { const response = await app.inject({ method: "GET", url: "/share/invalid-token/doses", }); expect(response.statusCode).toBe(404); }); it("should return 404 for mark dose with invalid share token", async () => { const response = await app.inject({ method: "POST", url: "/share/invalid-token/doses", payload: { doseId: "1-0-1735344000000" }, }); expect(response.statusCode).toBe(404); }); it("should return 404 for unmark dose with invalid share token", async () => { const response = await app.inject({ method: "DELETE", url: "/share/invalid-token/doses/1-0-1735344000000", }); expect(response.statusCode).toBe(404); }); it("should return validation error for empty doseId in share route", async () => { await createMedication(testClient, userId, "Aspirin", ["Daniel"]); const token = "validation-test-token"; await createShareToken(testClient, userId, "Daniel", token); const response = await app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId: "" }, }); expect(response.statusCode).toBe(400); }); }); // --------------------------------------------------------------------------- // Medication Image Routes // --------------------------------------------------------------------------- describe("Medication image routes", () => { it("should return 400 for invalid medication id in image upload", async () => { const response = await app.inject({ method: "POST", url: "/medications/invalid/image", }); expect(response.statusCode).toBe(400); }); it("should return 404 for image upload to non-existent medication", async () => { const response = await app.inject({ method: "POST", url: "/medications/99999/image", }); expect(response.statusCode).toBe(404); }); it("should return error for image upload without file", async () => { // Create medication first const createResponse = await app.inject({ method: "POST", url: "/medications", payload: { name: "Image Test Med", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, }); const medId = createResponse.json().id; const response = await app.inject({ method: "POST", url: `/medications/${medId}/image`, }); // 406 Not Acceptable when no multipart content expect([400, 406]).toContain(response.statusCode); }); it("should return 400 for invalid medication id in image delete", async () => { const response = await app.inject({ method: "DELETE", url: "/medications/invalid/image", }); expect(response.statusCode).toBe(400); }); it("should return 404 for image delete on non-existent medication", async () => { const response = await app.inject({ method: "DELETE", url: "/medications/99999/image", }); expect(response.statusCode).toBe(404); }); it("should handle image delete when no image exists", async () => { // Create medication first (without image) const createResponse = await app.inject({ method: "POST", url: "/medications", payload: { name: "No Image Med", blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, }); const medId = createResponse.json().id; const response = await app.inject({ method: "DELETE", url: `/medications/${medId}/image`, }); // Returns 204 No Content expect(response.statusCode).toBe(204); }); }); });