import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; import Fastify, { FastifyInstance } from "fastify"; import { createClient, Client } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; // Create test database and mocks before anything else (hoisted) const { testClient, testDb, mockSendMail, mockSendShoutrrr, mockUpdateReminderSentTime, mockUpdateUserReminderSentTime } = vi.hoisted(() => { 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, mockSendMail: vi.fn(), mockSendShoutrrr: vi.fn(), mockUpdateReminderSentTime: vi.fn(), mockUpdateUserReminderSentTime: vi.fn(), }; }); // Mock nodemailer vi.mock("nodemailer", () => ({ default: { createTransport: vi.fn(() => ({ sendMail: mockSendMail, })), }, })); // Mock the db module vi.mock("../db/client.js", () => ({ db: testDb, migrationsReady: Promise.resolve(), })); // Mock env to disable auth vi.mock("../plugins/env.js", () => ({ env: { AUTH_ENABLED: false, JWT_SECRET: "test-secret-key-for-testing", JWT_REFRESH_SECRET: "test-refresh-secret-key", }, })); // Mock auth plugin vi.mock("../plugins/auth.js", () => ({ requireAuth: async () => {}, getAnonymousUserId: () => 999999999, })); // Mock reminder-scheduler vi.mock("../services/reminder-scheduler.js", () => ({ updateReminderSentTime: mockUpdateReminderSentTime, updateUserReminderSentTime: mockUpdateUserReminderSentTime, })); // Mock sendShoutrrrNotification from settings vi.mock("../routes/settings.js", async (importOriginal) => { const original = await importOriginal() as any; return { ...original, sendShoutrrrNotification: mockSendShoutrrr, }; }); import { plannerRoutes } from "../routes/planner.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, auth_provider text NOT NULL DEFAULT 'local', is_active integer NOT NULL DEFAULT 1, created_at integer NOT NULL DEFAULT (strftime('%s','now')), updated_at integer NOT NULL DEFAULT (strftime('%s','now')) )`, `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, skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, repeat_reminders_enabled integer NOT NULL DEFAULT 0, reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30, max_nagging_reminders integer NOT NULL DEFAULT 5, 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 )`, ]; for (const sql of tableCreations) { await client.execute(sql); } } async function clearData(client: Client) { await client.execute("DELETE FROM user_settings"); await client.execute("DELETE FROM users"); await client.execute("DELETE FROM sqlite_sequence"); } describe("Planner Routes", () => { let app: FastifyInstance; beforeAll(async () => { await createSchema(testClient); }); beforeEach(async () => { await clearData(testClient); // Create anonymous user await testClient.execute( "INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')" ); app = Fastify({ logger: false }); await app.register(plannerRoutes); await app.ready(); vi.clearAllMocks(); mockSendMail.mockReset(); mockSendShoutrrr.mockReset(); }); afterAll(async () => { await app?.close(); testClient.close(); }); describe("POST /planner/send-email", () => { it("should reject request with missing email", async () => { const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { from: "2025-01-01", until: "2025-01-31", rows: [{ medicationName: "Test", totalPills: 10, plannerUsage: 5, enough: true }], }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "Missing email or planner data" }); }); it("should reject request with missing rows", async () => { const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { email: "test@example.com", from: "2025-01-01", until: "2025-01-31", rows: [], }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "Missing email or planner data" }); }); it("should reject when SMTP is not configured", async () => { const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { email: "test@example.com", from: "2025-01-01", until: "2025-01-31", rows: [ { medicationId: 1, medicationName: "Aspirin", totalPills: 30, plannerUsage: 10, blisterSize: 10, blistersNeeded: 1, fullBlisters: 3, loosePills: 0, enough: true, }, ], }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "SMTP not configured" }); }); it("should send email successfully when SMTP is configured", async () => { // Set SMTP env vars process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; mockSendMail.mockResolvedValueOnce({ messageId: "123" }); const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { email: "test@example.com", from: "2025-01-01", until: "2025-01-31", language: "en", rows: [ { medicationId: 1, medicationName: "Aspirin", totalPills: 30, plannerUsage: 10, blisterSize: 10, blistersNeeded: 1, fullBlisters: 3, loosePills: 0, enough: true, }, ], }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, message: "Email sent successfully" }); expect(mockSendMail).toHaveBeenCalledTimes(1); // Cleanup delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should handle email with out of stock medications", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; mockSendMail.mockResolvedValueOnce({ messageId: "123" }); const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { email: "test@example.com", from: "2025-01-01", until: "2025-01-31", rows: [ { medicationId: 1, medicationName: "Aspirin", totalPills: 5, plannerUsage: 30, blisterSize: 10, blistersNeeded: 3, fullBlisters: 0, loosePills: 5, enough: false, }, { medicationId: 2, medicationName: "Ibuprofen", totalPills: 100, plannerUsage: 10, blisterSize: 10, blistersNeeded: 1, fullBlisters: 10, loosePills: 0, enough: true, }, ], }, }); expect(response.statusCode).toBe(200); expect(mockSendMail).toHaveBeenCalledTimes(1); // Check that HTML contains out of stock warning const mailCall = mockSendMail.mock.calls[0][0]; expect(mailCall.html).toContain("Out of Stock"); expect(mailCall.html).toContain("1 medication"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should handle SMTP error gracefully", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; mockSendMail.mockRejectedValueOnce(new Error("Connection refused")); const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { email: "test@example.com", from: "2025-01-01", until: "2025-01-31", rows: [ { medicationId: 1, medicationName: "Aspirin", totalPills: 30, plannerUsage: 10, blisterSize: 10, blistersNeeded: 1, fullBlisters: 3, loosePills: 0, enough: true, }, ], }, }); expect(response.statusCode).toBe(500); expect(response.json().error).toContain("Failed to send email"); expect(response.json().error).toContain("Connection refused"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should use German locale when language is de", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; mockSendMail.mockResolvedValueOnce({ messageId: "123" }); const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { email: "test@example.com", from: "2025-01-15", until: "2025-02-15", language: "de", rows: [ { medicationId: 1, medicationName: "Aspirin", totalPills: 30, plannerUsage: 10, blisterSize: 10, blistersNeeded: 1, fullBlisters: 3, loosePills: 0, enough: true, }, ], }, }); expect(response.statusCode).toBe(200); // German date format should be used const mailCall = mockSendMail.mock.calls[0][0]; expect(mailCall.subject).toContain("Supply Overview"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); }); describe("POST /reminder/send-email", () => { it("should reject request with missing lowStock data", async () => { const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [], }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "Missing low stock data" }); }); it("should reject request with no lowStock array", async () => { const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "Missing low stock data" }); }); it("should return error when no notification channels configured", async () => { // User settings exist but email/shoutrrr disabled await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`, args: [999999999], }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "No notification channels configured" }); }); it("should send email reminder when email is enabled", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; // Enable email in user settings await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, args: [999999999], }); mockSendMail.mockResolvedValueOnce({ messageId: "123" }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, message: "Reminder sent via email" }); expect(mockSendMail).toHaveBeenCalledTimes(1); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should handle empty medications (medsLeft <= 0)", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, args: [999999999], }); mockSendMail.mockResolvedValueOnce({ messageId: "123" }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null }, { name: "Ibuprofen", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(200); // Check email contains EMPTY warning const mailCall = mockSendMail.mock.calls[0][0]; expect(mailCall.subject).toContain("Empty"); expect(mailCall.html).toContain("EMPTY"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should handle mixed empty and low stock medications", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, args: [999999999], }); mockSendMail.mockResolvedValueOnce({ messageId: "123" }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null }, { name: "Ibuprofen", medsLeft: 10, daysLeft: 5, depletionDate: "2025-01-05" }, ], }, }); expect(response.statusCode).toBe(200); const mailCall = mockSendMail.mock.calls[0][0]; expect(mailCall.subject).toContain("Empty"); expect(mailCall.subject).toContain("Running Low"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should handle email error gracefully", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, args: [999999999], }); mockSendMail.mockRejectedValueOnce(new Error("SMTP error")); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(500); expect(response.json().error).toContain("Email:"); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should send push notification when shoutrrr is enabled", async () => { await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, args: [999999999], }); mockSendShoutrrr.mockResolvedValueOnce({ success: true }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, message: "Reminder sent via push" }); expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); }); it("should send both email and push when both enabled", async () => { process.env.SMTP_HOST = "smtp.test.com"; process.env.SMTP_USER = "user@test.com"; process.env.SMTP_PASS = "password"; await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 1, 1, 'ntfy://localhost/test', 'en')`, args: [999999999], }); mockSendMail.mockResolvedValueOnce({ messageId: "123" }); mockSendShoutrrr.mockResolvedValueOnce({ success: true }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, message: "Reminder sent via email and push" }); expect(mockSendMail).toHaveBeenCalledTimes(1); expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_PASS; }); it("should handle push notification error gracefully", async () => { await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, args: [999999999], }); mockSendShoutrrr.mockResolvedValueOnce({ success: false, error: "Connection failed" }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(500); expect(response.json().error).toContain("Push:"); }); it("should handle push with empty meds using German translations", async () => { await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'de')`, args: [999999999], }); mockSendShoutrrr.mockResolvedValueOnce({ success: true }); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null }, ], }, }); expect(response.statusCode).toBe(200); expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); // Check German translations are used const [title, message] = mockSendShoutrrr.mock.calls[0].slice(1); expect(title).toContain("Leer"); }); it("should handle push exception gracefully", async () => { await testClient.execute({ sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, args: [999999999], }); mockSendShoutrrr.mockRejectedValueOnce(new Error("Network error")); const response = await app.inject({ method: "POST", url: "/reminder/send-email", payload: { email: "test@example.com", lowStock: [ { name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }, ], }, }); expect(response.statusCode).toBe(500); expect(response.json().error).toContain("Push:"); expect(response.json().error).toContain("Network error"); }); }); });