89d565bc9d
* chore: fix lint errors and reduce warnings across codebase - Fix noExplicitAny catches in backend routes and plugins - Fix noNestedTernary issues in backend services - Add keyboard event handlers for useKeyWithClickEvents in frontend - Disable noImportantStyles rule in biome.json - Fix formatting errors across all changed files - Fix test file lint issues Closes #233 * fix: restore any types in test files for TS compatibility * fix: revert Auth.tsx dependency array changes that caused infinite re-render * fix: null-safe user.username access in AppContext dependency array
2561 lines
76 KiB
TypeScript
2561 lines
76 KiB
TypeScript
/**
|
||
* E2E Tests using the real routes against in-memory SQLite.
|
||
* These tests import the actual route handlers for real coverage.
|
||
*/
|
||
|
||
import cookie from "@fastify/cookie";
|
||
import jwt from "@fastify/jwt";
|
||
import fastifyMultipart from "@fastify/multipart";
|
||
import sensible from "@fastify/sensible";
|
||
import type { Client } from "@libsql/client";
|
||
import Fastify, { type FastifyInstance } from "fastify";
|
||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
// 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");
|
||
const { refillRoutes } = await import("../routes/refills.js");
|
||
const { exportRoutes } = await import("../routes/export.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 '[]',
|
||
package_type text NOT NULL DEFAULT 'blister',
|
||
pack_count integer NOT NULL DEFAULT 1,
|
||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||
total_pills integer,
|
||
loose_tablets integer NOT NULL DEFAULT 0,
|
||
stock_adjustment integer NOT NULL DEFAULT 0,
|
||
last_stock_correction_at integer,
|
||
pill_weight_mg integer,
|
||
dose_unit text DEFAULT 'mg',
|
||
usage_json text NOT NULL DEFAULT '[]',
|
||
every_json text NOT NULL DEFAULT '[]',
|
||
start_json text NOT NULL DEFAULT '[]',
|
||
intakes_json text NOT NULL DEFAULT '[]',
|
||
image_url text,
|
||
expiry_date text,
|
||
notes text,
|
||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||
medication_start_date text NOT NULL DEFAULT '',
|
||
is_obsolete integer NOT NULL DEFAULT 0,
|
||
obsolete_at integer,
|
||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||
prescription_authorized_refills integer,
|
||
prescription_remaining_refills integer,
|
||
prescription_low_refill_threshold integer NOT NULL DEFAULT 1,
|
||
prescription_expiry_date text,
|
||
dismissed_until 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 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,
|
||
email_prescription_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,
|
||
shoutrrr_prescription_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,
|
||
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
|
||
)`,
|
||
`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,
|
||
dismissed integer NOT NULL DEFAULT 0,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||
id integer PRIMARY KEY AUTOINCREMENT,
|
||
medication_id integer NOT NULL,
|
||
user_id integer NOT NULL,
|
||
packs_added integer NOT NULL DEFAULT 0,
|
||
loose_pills_added integer NOT NULL DEFAULT 0,
|
||
used_prescription integer NOT NULL DEFAULT 0,
|
||
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||
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 refill_history");
|
||
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<number> {
|
||
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<number> {
|
||
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<void> {
|
||
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.register(refillRoutes);
|
||
await app.register(exportRoutes);
|
||
|
||
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",
|
||
});
|
||
|
||
if (response.statusCode !== 200) {
|
||
console.error("GET /settings error:", response.body);
|
||
}
|
||
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);
|
||
const json = response.json();
|
||
expect(json.status).toBe("ok");
|
||
expect(typeof json.smtpConfigured).toBe("boolean");
|
||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
const json = response.json();
|
||
expect(json.status).toBe("ok");
|
||
expect(typeof json.smtpConfigured).toBe("boolean");
|
||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Real Refill Routes Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Real /medications/:id/refill routes", () => {
|
||
it("should add refill to medication stock", async () => {
|
||
// Create medication first
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Refill Test Med",
|
||
packCount: 2,
|
||
blistersPerPack: 3,
|
||
pillsPerBlister: 10,
|
||
looseTablets: 5,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
expect(createResponse.statusCode).toBe(200);
|
||
const medId = createResponse.json().id;
|
||
|
||
// Add refill
|
||
const refillResponse = await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
||
});
|
||
|
||
expect(refillResponse.statusCode).toBe(200);
|
||
const data = refillResponse.json();
|
||
expect(data.success).toBe(true);
|
||
expect(data.newStock.packCount).toBe(3); // 2 + 1
|
||
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
|
||
});
|
||
|
||
it("should decrement remaining refills and mark history when using prescription refill", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Prescription Refill Med",
|
||
packCount: 1,
|
||
blistersPerPack: 2,
|
||
pillsPerBlister: 10,
|
||
looseTablets: 0,
|
||
prescriptionEnabled: true,
|
||
prescriptionAuthorizedRefills: 3,
|
||
prescriptionRemainingRefills: 2,
|
||
prescriptionLowRefillThreshold: 1,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
expect(createResponse.statusCode).toBe(200);
|
||
const medId = createResponse.json().id;
|
||
|
||
const refillResponse = await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true },
|
||
});
|
||
|
||
expect(refillResponse.statusCode).toBe(200);
|
||
const refillData = refillResponse.json();
|
||
expect(refillData.prescription.used).toBe(true);
|
||
expect(refillData.prescription.remainingRefills).toBe(1);
|
||
|
||
const medsResponse = await app.inject({
|
||
method: "GET",
|
||
url: "/medications",
|
||
});
|
||
expect(medsResponse.statusCode).toBe(200);
|
||
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||
expect(med.prescriptionRemainingRefills).toBe(1);
|
||
|
||
const historyResponse = await app.inject({
|
||
method: "GET",
|
||
url: `/medications/${medId}/refills`,
|
||
});
|
||
expect(historyResponse.statusCode).toBe(200);
|
||
expect(historyResponse.json()[0].usedPrescription).toBe(true);
|
||
});
|
||
|
||
it("should reject prescription refill when no remaining prescription refills are available", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Prescription Empty Med",
|
||
packCount: 1,
|
||
blistersPerPack: 2,
|
||
pillsPerBlister: 10,
|
||
looseTablets: 0,
|
||
prescriptionEnabled: true,
|
||
prescriptionAuthorizedRefills: 2,
|
||
prescriptionRemainingRefills: 0,
|
||
prescriptionLowRefillThreshold: 1,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
expect(createResponse.statusCode).toBe(200);
|
||
const medId = createResponse.json().id;
|
||
|
||
const refillResponse = await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true },
|
||
});
|
||
|
||
expect(refillResponse.statusCode).toBe(409);
|
||
expect(refillResponse.json().error).toContain("No remaining prescription refills");
|
||
});
|
||
|
||
it("should return 400 when no packs or pills added", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Refill Test Med 2",
|
||
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}/refill`,
|
||
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
|
||
it("should return 404 for non-existent medication", async () => {
|
||
const response = await app.inject({
|
||
method: "POST",
|
||
url: "/medications/99999/refill",
|
||
payload: { packsAdded: 1 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(404);
|
||
});
|
||
|
||
it("should return 400 for invalid medication id", async () => {
|
||
const response = await app.inject({
|
||
method: "POST",
|
||
url: "/medications/invalid/refill",
|
||
payload: { packsAdded: 1 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe("Real /medications/:id/refills routes (history)", () => {
|
||
it("should return empty array when no refills", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "No Refill Med",
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
const response = await app.inject({
|
||
method: "GET",
|
||
url: `/medications/${medId}/refills`,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
expect(response.json()).toEqual([]);
|
||
});
|
||
|
||
it("should return refill history after adding refills", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "With Refills Med",
|
||
packCount: 1,
|
||
blistersPerPack: 2,
|
||
pillsPerBlister: 10,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Add two refills
|
||
await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||
});
|
||
await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 0, loosePillsAdded: 5 },
|
||
});
|
||
|
||
const response = await app.inject({
|
||
method: "GET",
|
||
url: `/medications/${medId}/refills`,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const refills = response.json();
|
||
expect(refills).toHaveLength(2);
|
||
// Check both refills exist (order may vary)
|
||
const hasPackRefill = refills.some((r: Record<string, unknown>) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||
const hasLooseRefill = refills.some(
|
||
(r: Record<string, unknown>) => r.packsAdded === 0 && r.loosePillsAdded === 5
|
||
);
|
||
expect(hasPackRefill).toBe(true);
|
||
expect(hasLooseRefill).toBe(true);
|
||
});
|
||
|
||
it("should return 404 for non-existent medication", async () => {
|
||
const response = await app.inject({
|
||
method: "GET",
|
||
url: "/medications/99999/refills",
|
||
});
|
||
|
||
expect(response.statusCode).toBe(404);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Real Stock Correction (PATCH /medications/:id/stock-adjustment) Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Real /medications/:id/stock-adjustment routes", () => {
|
||
it("should update stockAdjustment and lastStockCorrectionAt", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Stock Correction Med",
|
||
packCount: 1,
|
||
blistersPerPack: 14,
|
||
pillsPerBlister: 14,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
expect(createResponse.statusCode).toBe(200);
|
||
const medId = createResponse.json().id;
|
||
|
||
// Correct stock: set adjustment to -83 (196 base - 83 = 113 pills)
|
||
const response = await app.inject({
|
||
method: "PATCH",
|
||
url: `/medications/${medId}/stock-adjustment`,
|
||
payload: { stockAdjustment: -83 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.stockAdjustment).toBe(-83);
|
||
expect(data.lastStockCorrectionAt).toBeTruthy();
|
||
expect(data.updatedAt).toBeTruthy();
|
||
});
|
||
|
||
it("should persist stockAdjustment in GET /medications", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Persist Stock Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Apply stock correction
|
||
await app.inject({
|
||
method: "PATCH",
|
||
url: `/medications/${medId}/stock-adjustment`,
|
||
payload: { stockAdjustment: -7 },
|
||
});
|
||
|
||
// Verify via GET
|
||
const getResponse = await app.inject({
|
||
method: "GET",
|
||
url: "/medications",
|
||
});
|
||
|
||
expect(getResponse.statusCode).toBe(200);
|
||
const meds = getResponse.json();
|
||
const med = meds.find((m: Record<string, unknown>) => m.id === medId);
|
||
expect(med).toBeDefined();
|
||
expect(med.stockAdjustment).toBe(-7);
|
||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||
});
|
||
|
||
it("should not reset stockAdjustment when editing medication via PUT", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Keep Adjustment Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Set stock adjustment
|
||
await app.inject({
|
||
method: "PATCH",
|
||
url: `/medications/${medId}/stock-adjustment`,
|
||
payload: { stockAdjustment: -5 },
|
||
});
|
||
|
||
// Edit the medication (change name) - should preserve stockAdjustment
|
||
await app.inject({
|
||
method: "PUT",
|
||
url: `/medications/${medId}`,
|
||
payload: {
|
||
name: "Renamed Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
looseTablets: 0,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
|
||
// Verify stockAdjustment is preserved
|
||
const getResponse = await app.inject({
|
||
method: "GET",
|
||
url: "/medications",
|
||
});
|
||
const med = getResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||
expect(med.name).toBe("Renamed Med");
|
||
expect(med.stockAdjustment).toBe(-5);
|
||
});
|
||
|
||
it("should return 400 for non-numeric stockAdjustment", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Bad Adjustment Med",
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
const response = await app.inject({
|
||
method: "PATCH",
|
||
url: `/medications/${medId}/stock-adjustment`,
|
||
payload: { stockAdjustment: "not-a-number" },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
|
||
it("should return 404 for non-existent medication", async () => {
|
||
const response = await app.inject({
|
||
method: "PATCH",
|
||
url: "/medications/99999/stock-adjustment",
|
||
payload: { stockAdjustment: 5 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(404);
|
||
});
|
||
|
||
it("should return 400 for invalid medication id", async () => {
|
||
const response = await app.inject({
|
||
method: "PATCH",
|
||
url: "/medications/invalid/stock-adjustment",
|
||
payload: { stockAdjustment: 5 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
|
||
it("should reset stockAdjustment when stock fields change via PUT", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Reset Adj Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
looseTablets: 0,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Set stock adjustment to -10
|
||
await app.inject({
|
||
method: "PATCH",
|
||
url: `/medications/${medId}/stock-adjustment`,
|
||
payload: { stockAdjustment: -10 },
|
||
});
|
||
|
||
// Verify adjustment is set
|
||
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||
let med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||
expect(med.stockAdjustment).toBe(-10);
|
||
|
||
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
||
await app.inject({
|
||
method: "PUT",
|
||
url: `/medications/${medId}`,
|
||
payload: {
|
||
name: "Reset Adj Med",
|
||
packCount: 2,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
looseTablets: 0,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
|
||
// stockAdjustment should be reset to 0
|
||
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||
med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||
expect(med.stockAdjustment).toBe(0);
|
||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||
});
|
||
|
||
it("should preserve stockAdjustment when only non-stock fields change via PUT", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Preserve Adj Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
looseTablets: 0,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Set stock adjustment
|
||
await app.inject({
|
||
method: "PATCH",
|
||
url: `/medications/${medId}/stock-adjustment`,
|
||
payload: { stockAdjustment: -5 },
|
||
});
|
||
|
||
// Edit only non-stock fields (name, notes)
|
||
await app.inject({
|
||
method: "PUT",
|
||
url: `/medications/${medId}`,
|
||
payload: {
|
||
name: "Renamed Preserve Med",
|
||
notes: "Updated notes",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
looseTablets: 0,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
|
||
// stockAdjustment should be preserved
|
||
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||
const med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||
expect(med.name).toBe("Renamed Preserve Med");
|
||
expect(med.stockAdjustment).toBe(-5);
|
||
});
|
||
|
||
it("should not count phantom consumption in planner after stock correction", async () => {
|
||
// Create medication: 1 pack × 14 blisters × 14 pills = 196 pills total
|
||
// Schedule: 1 pill daily starting far in the past
|
||
const farPast = new Date("2024-01-01T08:00:00.000Z");
|
||
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Planner Phantom Med",
|
||
packCount: 1,
|
||
blistersPerPack: 14,
|
||
pillsPerBlister: 14,
|
||
looseTablets: 0,
|
||
blisters: [{ usage: 1, every: 1, start: farPast.toISOString() }],
|
||
},
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Correct stock to 113 pills (196 base - 83 = 113)
|
||
await app.inject({
|
||
method: "PATCH",
|
||
url: `/medications/${medId}/stock-adjustment`,
|
||
payload: { stockAdjustment: -83 },
|
||
});
|
||
|
||
// Query planner immediately - stock should be ~113 (not reduced by phantom dose)
|
||
const tomorrow = new Date();
|
||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||
const nextWeek = new Date();
|
||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||
|
||
const response = await app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: tomorrow.toISOString(),
|
||
endDate: nextWeek.toISOString(),
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
const med = data.find((m: Record<string, unknown>) => m.medicationId === medId);
|
||
expect(med).toBeDefined();
|
||
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
||
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
||
expect(med.totalPills).toBeGreaterThanOrEqual(112);
|
||
expect(med.totalPills).toBeLessThanOrEqual(113);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Real Export/Import Routes Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Real /export routes", () => {
|
||
it("should export empty data when no medications", async () => {
|
||
const response = await app.inject({
|
||
method: "GET",
|
||
url: "/export",
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.version).toBeDefined();
|
||
expect(data.exportedAt).toBeDefined();
|
||
expect(data.medications).toEqual([]);
|
||
});
|
||
|
||
it("should export medications with correct structure", async () => {
|
||
// Create a medication
|
||
await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Export Test Med",
|
||
genericName: "Test Generic",
|
||
packCount: 2,
|
||
blistersPerPack: 3,
|
||
pillsPerBlister: 10,
|
||
looseTablets: 5,
|
||
pillWeightMg: 500,
|
||
notes: "Test notes",
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
|
||
const response = await app.inject({
|
||
method: "GET",
|
||
url: "/export",
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.medications).toHaveLength(1);
|
||
|
||
const med = data.medications[0];
|
||
expect(med.name).toBe("Export Test Med");
|
||
expect(med.genericName).toBe("Test Generic");
|
||
expect(med.inventory.packCount).toBe(2);
|
||
expect(med.inventory.blistersPerPack).toBe(3);
|
||
expect(med.inventory.pillsPerBlister).toBe(10);
|
||
expect(med.inventory.looseTablets).toBe(5);
|
||
expect(med.pillWeightMg).toBe(500);
|
||
expect(med.notes).toBe("Test notes");
|
||
expect(med.schedules).toHaveLength(1);
|
||
});
|
||
|
||
it("should include settings when user has settings", async () => {
|
||
// Create settings first
|
||
await app.inject({
|
||
method: "PUT",
|
||
url: "/settings",
|
||
payload: {
|
||
emailEnabled: true,
|
||
notificationEmail: "test@example.com",
|
||
},
|
||
});
|
||
|
||
const response = await app.inject({
|
||
method: "GET",
|
||
url: "/export",
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.settings).toBeDefined();
|
||
expect(data.settings.emailEnabled).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("Real /import routes", () => {
|
||
it("should import medications from export format", async () => {
|
||
const importData = {
|
||
version: "1.0",
|
||
exportedAt: new Date().toISOString(),
|
||
medications: [
|
||
{
|
||
_exportId: "med-1",
|
||
name: "Imported Med",
|
||
genericName: "Imported Generic",
|
||
takenBy: ["Person A"],
|
||
inventory: {
|
||
packCount: 3,
|
||
blistersPerPack: 2,
|
||
pillsPerBlister: 14,
|
||
looseTablets: 7,
|
||
},
|
||
pillWeightMg: 250,
|
||
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z", remind: true }],
|
||
notes: "Imported notes",
|
||
intakeRemindersEnabled: true,
|
||
},
|
||
],
|
||
};
|
||
|
||
const response = await app.inject({
|
||
method: "POST",
|
||
url: "/import",
|
||
payload: importData,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const result = response.json();
|
||
expect(result.success).toBe(true);
|
||
expect(result.imported.medications).toBe(1);
|
||
|
||
// Verify medication was created
|
||
const medsResponse = await app.inject({
|
||
method: "GET",
|
||
url: "/medications",
|
||
});
|
||
const meds = medsResponse.json();
|
||
expect(meds).toHaveLength(1);
|
||
expect(meds[0].name).toBe("Imported Med");
|
||
});
|
||
|
||
it("should return 400 for invalid import data", async () => {
|
||
const response = await app.inject({
|
||
method: "POST",
|
||
url: "/import",
|
||
payload: { invalid: "data" },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
|
||
it("should replace existing medications on import", async () => {
|
||
// First create a medication
|
||
await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: {
|
||
name: "Existing Med",
|
||
packCount: 5,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
});
|
||
|
||
// Verify it exists
|
||
let medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||
expect(medsResponse.json()).toHaveLength(1);
|
||
expect(medsResponse.json()[0].name).toBe("Existing Med");
|
||
expect(medsResponse.json()[0].packCount).toBe(5);
|
||
|
||
// Import will REPLACE all data
|
||
const importData = {
|
||
version: "1.0",
|
||
exportedAt: new Date().toISOString(),
|
||
medications: [
|
||
{
|
||
_exportId: "med-1",
|
||
name: "Imported Med",
|
||
inventory: { packCount: 10, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
|
||
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
],
|
||
};
|
||
|
||
const response = await app.inject({
|
||
method: "POST",
|
||
url: "/import",
|
||
payload: importData,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const result = response.json();
|
||
expect(result.success).toBe(true);
|
||
expect(result.imported.medications).toBe(1);
|
||
|
||
// Verify: old med is gone, new med exists
|
||
medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||
expect(medsResponse.json()).toHaveLength(1);
|
||
expect(medsResponse.json()[0].name).toBe("Imported Med");
|
||
expect(medsResponse.json()[0].packCount).toBe(10);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Package Type (bottle vs blister) Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Package type handling (bottle vs blister)", () => {
|
||
const bottleMedication = {
|
||
name: "Vitamin D Drops",
|
||
packageType: "bottle",
|
||
packCount: 0,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 1,
|
||
looseTablets: 120,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
};
|
||
|
||
const blisterMedication = {
|
||
name: "Aspirin Blister",
|
||
packageType: "blister",
|
||
packCount: 2,
|
||
blistersPerPack: 3,
|
||
pillsPerBlister: 10,
|
||
looseTablets: 5,
|
||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
};
|
||
|
||
it("should create and return bottle type medication", async () => {
|
||
const response = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: bottleMedication,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.packageType).toBe("bottle");
|
||
expect(data.looseTablets).toBe(120);
|
||
});
|
||
|
||
it("should return packageType in shared schedule for bottle type", async () => {
|
||
// Create bottle medication with takenBy
|
||
await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: { ...bottleMedication, takenBy: ["Daniel"] },
|
||
});
|
||
|
||
// Create share token
|
||
const shareResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/share",
|
||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||
});
|
||
expect(shareResponse.statusCode).toBe(200);
|
||
const { token } = shareResponse.json();
|
||
|
||
// Get shared schedule
|
||
const scheduleResponse = await app.inject({
|
||
method: "GET",
|
||
url: `/share/${token}`,
|
||
});
|
||
|
||
expect(scheduleResponse.statusCode).toBe(200);
|
||
const data = scheduleResponse.json();
|
||
expect(data.medications).toHaveLength(1);
|
||
expect(data.medications[0].packageType).toBe("bottle");
|
||
// Bottle totalPills = looseTablets + stockAdjustment (no blister math)
|
||
expect(data.medications[0].totalPills).toBe(120);
|
||
});
|
||
|
||
it("should calculate correct totalPills for shared blister medication", async () => {
|
||
await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: { ...blisterMedication, takenBy: ["Daniel"] },
|
||
});
|
||
|
||
const shareResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/share",
|
||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||
});
|
||
const { token } = shareResponse.json();
|
||
|
||
const scheduleResponse = await app.inject({
|
||
method: "GET",
|
||
url: `/share/${token}`,
|
||
});
|
||
|
||
expect(scheduleResponse.statusCode).toBe(200);
|
||
const data = scheduleResponse.json();
|
||
expect(data.medications).toHaveLength(1);
|
||
expect(data.medications[0].packageType).toBe("blister");
|
||
// Blister totalPills = 2 * 3 * 10 + 5 = 65
|
||
expect(data.medications[0].totalPills).toBe(65);
|
||
});
|
||
|
||
it("should calculate correct refill totalPillsAdded for bottle type", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: bottleMedication,
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Refill bottle: only loosePillsAdded matters, packs should add 0 pills
|
||
const refillResponse = await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 0, loosePillsAdded: 30 },
|
||
});
|
||
|
||
expect(refillResponse.statusCode).toBe(200);
|
||
const data = refillResponse.json();
|
||
expect(data.refill.totalPillsAdded).toBe(30);
|
||
// newStock.totalPills should be looseTablets only (no blister math)
|
||
expect(data.newStock.totalPills).toBe(150); // 120 + 30
|
||
});
|
||
|
||
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: blisterMedication,
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Refill blister: 1 pack = 3 blisters * 10 pills = 30 pills + 5 loose
|
||
const refillResponse = await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1, loosePillsAdded: 5 },
|
||
});
|
||
|
||
expect(refillResponse.statusCode).toBe(200);
|
||
const data = refillResponse.json();
|
||
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
|
||
});
|
||
|
||
it("should return correct totalPillsAdded in refill history for bottle type", async () => {
|
||
const createResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: bottleMedication,
|
||
});
|
||
const medId = createResponse.json().id;
|
||
|
||
// Add refill
|
||
await app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 0, loosePillsAdded: 25 },
|
||
});
|
||
|
||
// Get refill history
|
||
const historyResponse = await app.inject({
|
||
method: "GET",
|
||
url: `/medications/${medId}/refills`,
|
||
});
|
||
|
||
expect(historyResponse.statusCode).toBe(200);
|
||
const refills = historyResponse.json();
|
||
expect(refills).toHaveLength(1);
|
||
// For bottle type, totalPillsAdded = loosePillsAdded only
|
||
expect(refills[0].totalPillsAdded).toBe(25);
|
||
});
|
||
|
||
it("should export and import bottle type medication correctly", async () => {
|
||
// Create bottle medication
|
||
await app.inject({
|
||
method: "POST",
|
||
url: "/medications",
|
||
payload: bottleMedication,
|
||
});
|
||
|
||
// Export
|
||
const exportResponse = await app.inject({
|
||
method: "GET",
|
||
url: "/export",
|
||
});
|
||
|
||
expect(exportResponse.statusCode).toBe(200);
|
||
const exportData = exportResponse.json();
|
||
expect(exportData.medications).toHaveLength(1);
|
||
expect(exportData.medications[0].inventory.packageType).toBe("bottle");
|
||
expect(exportData.medications[0].inventory.looseTablets).toBe(120);
|
||
|
||
// Clear and re-import
|
||
await clearData(testClient);
|
||
await testClient.execute(
|
||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||
);
|
||
|
||
const importResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/import",
|
||
payload: exportData,
|
||
});
|
||
|
||
expect(importResponse.statusCode).toBe(200);
|
||
expect(importResponse.json().success).toBe(true);
|
||
|
||
// Verify imported medication has correct packageType
|
||
const medsResponse = await app.inject({
|
||
method: "GET",
|
||
url: "/medications",
|
||
});
|
||
|
||
expect(medsResponse.json()).toHaveLength(1);
|
||
const med = medsResponse.json()[0];
|
||
expect(med.name).toBe("Vitamin D Drops");
|
||
expect(med.packageType).toBe("bottle");
|
||
expect(med.looseTablets).toBe(120);
|
||
});
|
||
|
||
it("should default to blister when importing without packageType", async () => {
|
||
const importData = {
|
||
version: "1.0",
|
||
exportedAt: new Date().toISOString(),
|
||
medications: [
|
||
{
|
||
_exportId: "med-1",
|
||
name: "Old Export Med",
|
||
inventory: { packCount: 2, blistersPerPack: 3, pillsPerBlister: 10, looseTablets: 0 },
|
||
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||
},
|
||
],
|
||
};
|
||
|
||
const importResponse = await app.inject({
|
||
method: "POST",
|
||
url: "/import",
|
||
payload: importData,
|
||
});
|
||
|
||
expect(importResponse.statusCode).toBe(200);
|
||
|
||
const medsResponse = await app.inject({
|
||
method: "GET",
|
||
url: "/medications",
|
||
});
|
||
|
||
expect(medsResponse.json()).toHaveLength(1);
|
||
expect(medsResponse.json()[0].packageType).toBe("blister");
|
||
});
|
||
});
|
||
});
|