Files
medassist-ng/backend/src/test/integration.test.ts
T
Daniel Volz d0a40bde88 feat: Nagging reminders with max limit + ENV defaults for settings (#18)
* ci: prevent duplicate test runs - tests only on PRs, inline tests for builds

* docs: add testing and CI/CD documentation

* security: fix CodeQL vulnerabilities (SSRF, XSS, rate limiting)

- Add URL validation to prevent SSRF attacks on notification endpoints
  - Block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x)
  - Block localhost and internal hostnames
  - Only allow HTTP/HTTPS protocols
- Add HTML escaping for medication names in email templates (XSS)
- Add stricter rate limiting for auth routes (5 req/15min for login/register)
- Add SSRF protection tests (405 tests total)

* security: add rate limiting to remaining auth routes

* chore: add CodeQL config to suppress rate-limit false positives

Rate limiting IS implemented via @fastify/rate-limit plugin:
- Global: 100 req/min (index.ts)
- Auth routes: 5-10 req/min via config.rateLimit option

CodeQL doesn't recognize Fastify's plugin-based rate limiting pattern.

* ci: switch to CodeQL Advanced Setup

- Add custom codeql.yml workflow
- Configure to use codeql-config.yml
- Exclude js/missing-rate-limiting rule (false positive)
  Rate limiting is implemented via @fastify/rate-limit plugin

* ci: add explicit permissions to workflows

Fixes CodeQL 'Workflow does not contain permissions' warnings.
Sets minimal 'contents: read' at top level.

* ci: add manual trigger to CodeQL workflow

* ci: add explicit permissions to all workflow jobs

* build(deps): bump esbuild, @vitest/coverage-v8 and vitest in /backend

Bumps [esbuild](https://github.com/evanw/esbuild) to 0.27.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `esbuild` from 0.21.5 to 0.27.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2)

Updates `@vitest/coverage-v8` from 2.1.9 to 4.0.16
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/coverage-v8)

Updates `vitest` from 2.1.9 to 4.0.16
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.2
  dependency-type: indirect
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.16
  dependency-type: direct:development
- dependency-name: vitest
  dependency-version: 4.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* docs: add GitHub issue templates

- Bug report template with deployment type, browser info, logs
- Feature request template with affected area, priority
- Config with link to discussions and README
- Optimize test.yml to skip tests for non-code changes

* Initial plan

* Remove database schema duplication by creating shared schema-sql.ts module

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Refactor frontend date formatting to eliminate duplication

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* docs: Add branch protection warning and PR workflow to instructions

* ci: remove paths filter from test workflow to fix branch protection

* fix: add .js extension to schema-sql imports for ESM compatibility (#15)

* feat: add setting to skip reminders for taken doses

- Add skipRemindersForTakenDoses setting to database schema
- Extend settings API to save and load new setting
- Update intake reminder scheduler to filter taken doses
- Add frontend toggle in settings with i18n (EN/DE)
- Only check doses from today (timezone-aware)
- Update all test schemas with new field
- All 405 tests passing

* feat: add repeat reminders for missed doses

- Add repeatRemindersEnabled and reminderRepeatIntervalMinutes settings
- Refactor intake reminder state from array to object with sendCount tracking
- Update scheduler to send repeated reminders at configurable intervals
- Only remind for today's doses (timezone-aware filtering)
- Add frontend toggle and interval input (5-480 minutes) in settings
- Maintain backward compatibility for old state file format
- Update all test schemas and assertions
- All 406 tests passing

* feat: add nagging reminders with max limit and ENV defaults

- Add maxNaggingReminders setting to limit repeat reminders (1-20)
- Add ENV defaults for all user settings (DEFAULT_*)
- Add ALTER TABLE migrations for backward compatibility
- Add smtpConfigured/shoutrrrConfigured to health endpoint
- Fix Push toggle to allow enabling without existing URL
- Disable skip/repeat toggles when no notifications enabled
- Add Pocket ID button to registration page
- Add getTodaysIntakes() for repeat reminder logic
- Update translations (en/de) for new settings
- Add comprehensive tests for new features

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-01-10 21:05:44 +01:00

937 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Integration Tests - Testing interactions between multiple routes/features
* These tests verify critical app behavior that spans multiple components.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import Fastify, { FastifyInstance } from "fastify";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import fastifyMultipart from "@fastify/multipart";
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = 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 };
});
// Mock modules
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,
},
}));
vi.mock("../plugins/auth.js", () => ({
requireAuth: async () => {},
getAnonymousUserId: () => 999999999,
}));
// Import routes
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");
// =============================================================================
// Schema & Setup
// =============================================================================
async function createSchema(client: Client) {
const tables = [
`CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
max_nagging_reminders integer NOT NULL DEFAULT 5,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
for (const sql of tables) {
await client.execute(sql);
}
}
async function clearData(client: Client) {
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
await client.execute("DELETE FROM user_settings");
await client.execute("DELETE FROM medications");
await client.execute("DELETE FROM users");
await client.execute("DELETE FROM sqlite_sequence");
}
// =============================================================================
// Tests
// =============================================================================
describe("Integration Tests", () => {
let app: FastifyInstance;
const userId = 999999999;
beforeAll(async () => {
await createSchema(testClient);
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: "/" },
});
await app.register(doseRoutes);
await app.register(shareRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
await clearData(testClient);
await testClient.execute(
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
);
});
// ---------------------------------------------------------------------------
// Medication Update + Dose Tracking Cleanup
// ---------------------------------------------------------------------------
describe("Medication Update cleans up old dose tracking", () => {
it("should delete doses before new start date when start date is moved forward", async () => {
// Create medication starting Jan 1
const createRes = 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 = createRes.json().id;
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
const jan1 = new Date("2025-01-01T08:00:00.000Z").getTime();
const jan2 = new Date("2025-01-02T08:00:00.000Z").getTime();
const jan5 = new Date("2025-01-05T08:00:00.000Z").getTime();
const jan10 = new Date("2025-01-10T08:00:00.000Z").getTime();
for (const ts of [jan1, jan2, jan5, jan10]) {
await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: `${medId}-0-${ts}` },
});
}
// Verify 4 doses exist
const beforeUpdate = await testClient.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`,
args: [`${medId}-%`],
});
expect(beforeUpdate.rows[0].count).toBe(4);
// Update medication to start Jan 5 (should delete Jan 1 and Jan 2 doses)
await app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Test Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-05T08:00:00.000Z" }],
},
});
// Verify only 2 doses remain (Jan 5 and Jan 10)
const afterUpdate = await testClient.execute({
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ? ORDER BY dose_id`,
args: [`${medId}-%`],
});
expect(afterUpdate.rows.length).toBe(2);
expect(afterUpdate.rows[0].dose_id).toContain(String(jan5));
expect(afterUpdate.rows[1].dose_id).toContain(String(jan10));
});
it("should keep all doses when start date is moved backward", async () => {
// Create medication starting Jan 10
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Test Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }],
},
});
const medId = createRes.json().id;
// Mark dose on Jan 10
const jan10 = new Date("2025-01-10T08:00:00.000Z").getTime();
await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: `${medId}-0-${jan10}` },
});
// Update to start Jan 1 (earlier)
await app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Test Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Dose should still exist
const afterUpdate = await testClient.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`,
args: [`${medId}-%`],
});
expect(afterUpdate.rows[0].count).toBe(1);
});
it("should handle multiple blisters with different start dates", async () => {
// Create medication with 2 schedules: Jan 1 morning and Jan 5 evening
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Test Med",
blisters: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
],
},
});
const medId = createRes.json().id;
// Mark doses for both schedules
const jan1_8am = new Date("2025-01-01T08:00:00.000Z").getTime();
const jan3_8am = new Date("2025-01-03T08:00:00.000Z").getTime();
const jan5_8pm = new Date("2025-01-05T20:00:00.000Z").getTime();
const jan6_8pm = new Date("2025-01-06T20:00:00.000Z").getTime();
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-${jan1_8am}` } });
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-${jan3_8am}` } });
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-1-${jan5_8pm}` } });
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-1-${jan6_8pm}` } });
// 4 doses total
const before = await testClient.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`,
args: [`${medId}-%`],
});
expect(before.rows[0].count).toBe(4);
// Update: move first schedule to Jan 4
// Earliest start is now Jan 4, so Jan 1 and Jan 3 doses should be deleted
await app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Test Med",
blisters: [
{ usage: 1, every: 1, start: "2025-01-04T08:00:00.000Z" },
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
],
},
});
// Should have 2 doses left (Jan 5 and Jan 6 evening doses)
const after = await testClient.execute({
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ?`,
args: [`${medId}-%`],
});
expect(after.rows.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Share Link + Dose Tracking Integration
// ---------------------------------------------------------------------------
describe("Share links and dose tracking integration", () => {
it("should allow marking/unmarking doses via share link with correct markedBy", async () => {
// Create medication for Daniel
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Aspirin",
takenBy: ["Daniel"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createRes.json().id;
// Create share token for Daniel
const shareRes = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
const token = shareRes.json().token;
// Mark dose via share link
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
// Verify markedBy is "Daniel"
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");
// Unmark via share link
await app.inject({
method: "DELETE",
url: `/share/${token}/doses/${encodeURIComponent(doseId)}`,
});
// Verify deleted
const afterDelete = await testClient.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(afterDelete.rows[0].count).toBe(0);
});
it("should show medication in shared schedule after marking dose", async () => {
// Create medication
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Vitamin D",
takenBy: ["Anna"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createRes.json().id;
// Create share token
const shareRes = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Anna", scheduleDays: 30 },
});
const token = shareRes.json().token;
// Mark a dose
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
// Get shared schedule
const scheduleRes = await app.inject({
method: "GET",
url: `/share/${token}`,
});
const data = scheduleRes.json();
expect(data.takenBy).toBe("Anna");
expect(data.medications).toHaveLength(1);
expect(data.medications[0].name).toBe("Vitamin D");
});
});
// ---------------------------------------------------------------------------
// Settings + Stock Calculation Mode
// ---------------------------------------------------------------------------
describe("Settings affect stock calculation", () => {
it("should persist stock calculation mode across requests", async () => {
// Set to manual mode
await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
language: "en",
stockCalculationMode: "manual",
},
});
// Verify it's saved
const getRes = await app.inject({
method: "GET",
url: "/settings",
});
expect(getRes.json().stockCalculationMode).toBe("manual");
// Change to automatic
await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: false,
notificationEmail: "",
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",
},
});
const getRes2 = await app.inject({
method: "GET",
url: "/settings",
});
expect(getRes2.json().stockCalculationMode).toBe("automatic");
});
});
// ---------------------------------------------------------------------------
// Multi-Person Medication Scenarios
// ---------------------------------------------------------------------------
describe("Multi-person medication scenarios", () => {
it("should create separate share links for different people", async () => {
// Create medication for multiple people
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Family Vitamins",
takenBy: ["Daniel", "Anna", "Max"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Create share links for each person
const danielShare = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
const annaShare = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Anna", scheduleDays: 30 },
});
// Both should succeed with different tokens
expect(danielShare.statusCode).toBe(200);
expect(annaShare.statusCode).toBe(200);
expect(danielShare.json().token).not.toBe(annaShare.json().token);
// Each share link should show correct person
const danielSchedule = await app.inject({
method: "GET",
url: `/share/${danielShare.json().token}`,
});
expect(danielSchedule.json().takenBy).toBe("Daniel");
const annaSchedule = await app.inject({
method: "GET",
url: `/share/${annaShare.json().token}`,
});
expect(annaSchedule.json().takenBy).toBe("Anna");
});
it("should list all people correctly via /share/people", async () => {
// Create multiple medications
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Med 1",
takenBy: ["Daniel", "Anna"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Med 2",
takenBy: ["Max"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Med 3",
takenBy: ["Daniel"], // Daniel again
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Get all people
const peopleRes = await app.inject({
method: "GET",
url: "/share/people",
});
const people = peopleRes.json().people;
expect(people).toContain("Daniel");
expect(people).toContain("Anna");
expect(people).toContain("Max");
expect(people.length).toBe(3); // No duplicates
});
});
// ---------------------------------------------------------------------------
// Edge Cases
// ---------------------------------------------------------------------------
describe("Edge cases", () => {
it("should handle medication with 0 stock correctly", async () => {
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Empty Med",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createRes.statusCode).toBe(200);
expect(createRes.json().packCount).toBe(0);
});
it("should handle medication with very high pill count", async () => {
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Bulk Med",
packCount: 100,
blistersPerPack: 10,
pillsPerBlister: 100,
looseTablets: 500,
blisters: [{ usage: 0.5, every: 7, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createRes.statusCode).toBe(200);
// Total: 100 * 10 * 100 + 500 = 100500 pills
});
it("should handle fractional pill usage", async () => {
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Half-Pill Med",
blisters: [
{ usage: 0.5, every: 1, start: "2025-01-01T08:00:00.000Z" },
{ usage: 0.25, every: 1, start: "2025-01-01T20:00:00.000Z" },
],
},
});
expect(createRes.statusCode).toBe(200);
expect(createRes.json().blisters[0].usage).toBe(0.5);
expect(createRes.json().blisters[1].usage).toBe(0.25);
});
it("should handle weekly medication schedule", async () => {
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Weekly Med",
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createRes.statusCode).toBe(200);
expect(createRes.json().blisters[0].every).toBe(7);
});
});
// ---------------------------------------------------------------------------
// Planner Usage Calculation - POST /medications/usage
// This is a CRITICAL feature for the app - calculates if stock is enough
// ---------------------------------------------------------------------------
describe("Planner usage calculation", () => {
it("should calculate correct usage for daily medication", async () => {
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
// Schedule: 1 pill daily starting Jan 1
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Daily Med",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Calculate usage for Jan 1-10 (10 days = 10 pills needed)
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-11T00:00:00.000Z", // 10 days
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data).toHaveLength(1);
expect(data[0].medicationName).toBe("Daily Med");
expect(data[0].plannerUsage).toBe(10); // 10 days × 1 pill
// Note: 'enough' depends on current stock after consumption since start date
// Since test runs ~364 days after Jan 1, most pills are consumed
});
it("should detect insufficient stock", async () => {
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
// Schedule: 1 pill daily
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Low Stock Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 5,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Calculate usage for 10 days (needs 10 pills, only have 5 originally)
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-11T00:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data[0].plannerUsage).toBe(10);
expect(data[0].enough).toBe(false); // Not enough!
});
it("should calculate weekly medication usage correctly", async () => {
// Create medication: 10 pills total
// Schedule: 1 pill every 7 days starting Jan 1
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Weekly Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Calculate usage for 30 days (should need ~4-5 pills)
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z", // 30 days
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
// Jan 1, 8, 15, 22, 29 = 5 doses
expect(data[0].plannerUsage).toBe(5);
});
it("should handle multiple intake schedules per medication", async () => {
// Create medication with morning and evening doses
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Twice Daily Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
blisters: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, // Morning: 1 pill
{ usage: 0.5, every: 1, start: "2025-01-01T20:00:00.000Z" }, // Evening: 0.5 pill
],
},
});
// Calculate for 10 days
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-11T00:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
// 10 days × (1 + 0.5) = 15 pills
expect(data[0].plannerUsage).toBe(15);
});
it("should calculate correct blisters needed", async () => {
// 10 pills per blister, need 25 pills → need 3 blisters
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Blister Med",
packCount: 5,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 2.5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// 10 days × 2.5 pills = 25 pills needed
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-11T00:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data[0].plannerUsage).toBe(25);
expect(data[0].blistersNeeded).toBe(3); // ceil(25/10)
expect(data[0].blisterSize).toBe(10);
});
it("should reject invalid date range", async () => {
// End before start
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2025-01-15T00:00:00.000Z",
endDate: "2025-01-01T00:00:00.000Z",
},
});
expect(response.statusCode).toBe(400);
});
it("should handle medication not yet started", async () => {
// Medication starts in the future
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Future Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
blisters: [{ usage: 1, every: 1, start: "2025-06-01T08:00:00.000Z" }], // Starts June
},
});
// Query for January (before start)
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data[0].plannerUsage).toBe(0); // No usage before start
});
it("should return correct totalPills based on current stock", async () => {
// Fresh medication with future start date = no consumption yet
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Fresh Med",
packCount: 2,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
// Start in far future so no consumption
blisters: [{ usage: 1, every: 1, start: "2030-01-01T08:00:00.000Z" }],
},
});
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2030-01-01T00:00:00.000Z",
endDate: "2030-01-11T00:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
// Total: 2 packs × 2 blisters × 10 pills + 5 loose = 45 pills
expect(data[0].totalPills).toBe(45);
expect(data[0].plannerUsage).toBe(10);
expect(data[0].enough).toBe(true); // 45 > 10
});
});
});