f56f2b7c88
- Separate stock/intake reminder tracking in DB with dedicated columns - Add shareStockStatus setting to control stock visibility on shared links - Rewrite planner notification to support both email and Shoutrrr push - Add push notification footer text for intake and stock reminders - New DB migrations: stock_reminder_tracking (0006), share_stock_status (0007) - Update backend i18n with demandCalculator section and critically low text - Add 514 passing backend tests including new coverage for all changes
984 lines
29 KiB
TypeScript
984 lines
29 KiB
TypeScript
import type { Client } from "@libsql/client";
|
|
import Fastify, { type FastifyInstance } from "fastify";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// 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',
|
|
share_stock_status integer NOT NULL DEFAULT 1,
|
|
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,
|
|
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 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 planner 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: "/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: "No notification channels 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";
|
|
|
|
// 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: "/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: "Notification sent via email" });
|
|
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";
|
|
|
|
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: "/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("Empty");
|
|
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";
|
|
|
|
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("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("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";
|
|
|
|
// User settings with German language
|
|
await testClient.execute({
|
|
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'de')`,
|
|
args: [999999999],
|
|
});
|
|
|
|
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("Bestandsübersicht");
|
|
|
|
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: "/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(200);
|
|
expect(response.json()).toEqual({ success: true, message: "Notification sent via push" });
|
|
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify push message contains medication info
|
|
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
|
|
expect(title).toContain("Supply Overview");
|
|
expect(message).toContain("Aspirin");
|
|
});
|
|
|
|
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: "/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,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ success: true, message: "Notification sent via email and push" });
|
|
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
|
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify push message contains out of stock info
|
|
const [_url, _title, message] = mockSendShoutrrr.mock.calls[0];
|
|
expect(message).toContain("Aspirin");
|
|
expect(message).toContain("Empty");
|
|
|
|
delete process.env.SMTP_HOST;
|
|
delete process.env.SMTP_USER;
|
|
delete process.env.SMTP_PASS;
|
|
});
|
|
|
|
it("should send push with 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: "/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,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
// Check German translations in push
|
|
const [_url, title] = mockSendShoutrrr.mock.calls[0];
|
|
expect(title).toContain("Bestandsübersicht");
|
|
});
|
|
|
|
it("should handle push 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: "/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("Push:");
|
|
expect(response.json().error).toContain("Connection failed");
|
|
});
|
|
});
|
|
|
|
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("Critical");
|
|
|
|
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");
|
|
});
|
|
|
|
it("should differentiate critical and low stock in push notification", 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", isCritical: true },
|
|
{ name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false },
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
|
|
// Title should contain both Critical and Low labels
|
|
expect(title).toContain("Critical");
|
|
expect(title).toContain("Low");
|
|
// Message should have separate sections
|
|
expect(message).toContain("Running critically low");
|
|
expect(message).toContain("Aspirin");
|
|
expect(message).toContain("Running low");
|
|
expect(message).toContain("Ibuprofen");
|
|
});
|
|
|
|
it("should differentiate critical and low stock in email", 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: 5, daysLeft: 3, depletionDate: "2025-01-03", isCritical: true },
|
|
{ name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false },
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const mailCall = mockSendMail.mock.calls[0][0];
|
|
// Subject should contain both Critical and Low
|
|
expect(mailCall.subject).toContain("Critical");
|
|
expect(mailCall.subject).toContain("Low");
|
|
// HTML should have separate alert boxes
|
|
expect(mailCall.html).toContain("critically low");
|
|
expect(mailCall.html).toContain("running low");
|
|
|
|
delete process.env.SMTP_HOST;
|
|
delete process.env.SMTP_USER;
|
|
delete process.env.SMTP_PASS;
|
|
});
|
|
|
|
it("should label all meds as critical when isCritical not provided", 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);
|
|
|
|
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
|
|
// Should be treated as critical (backwards compat)
|
|
expect(title).toContain("Critical");
|
|
expect(title).not.toContain("Low");
|
|
expect(message).toContain("Running critically low");
|
|
});
|
|
});
|
|
});
|