Files
medassist-ng/backend/src/test/stock-semantics-parity.test.ts
T
Daniel Volz c291c88f2b fix(notifications): fallback to generic medication names (#532)
* fix(notifications): fallback to generic medication names

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:34:06 +02:00

413 lines
13 KiB
TypeScript

import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = 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,
mockedEnv: {
AUTH_ENABLED: false,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
vi.mock("../plugins/auth.js", () => ({
requireAuth: async () => {},
getAnonymousUserId: async () => 1,
}));
const { medicationRoutes } = await import("../routes/medications.js");
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM users");
}
async function seedAnonymousUser() {
await testClient.execute({
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
args: [1, "anon", "anonymous"],
});
}
async function setStockMode(mode: "automatic" | "manual") {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
VALUES (?, ?, 7, 365, 'en')`,
args: [1, mode],
});
}
async function createMedication(options: {
name: string;
genericName?: string | null;
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
looseTablets?: number;
stockAdjustment?: number;
lastStockCorrectionAt?: number | null;
isObsolete?: boolean;
takenBy?: string[];
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
}) {
const {
name,
genericName = null,
packCount = 1,
blistersPerPack = 1,
pillsPerBlister = 10,
looseTablets = 0,
stockAdjustment = 0,
lastStockCorrectionAt = null,
isObsolete = false,
takenBy = [],
intakes,
} = options;
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
const everyJson = JSON.stringify(intakes.map((i) => i.every));
const startJson = JSON.stringify(intakes.map((i) => i.start));
const intakesJson = JSON.stringify(
intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
takenBy: i.takenBy ?? null,
intakeRemindersEnabled: false,
}))
);
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
stock_adjustment, last_stock_correction_at,
usage_json, every_json, start_json, intakes_json,
is_obsolete, intake_reminders_enabled
) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
RETURNING id`,
args: [
1,
name,
genericName,
JSON.stringify(takenBy),
packCount,
blistersPerPack,
pillsPerBlister,
looseTablets,
stockAdjustment,
lastStockCorrectionAt,
usageJson,
everyJson,
startJson,
intakesJson,
isObsolete ? 1 : 0,
],
});
return Number(result.rows[0].id);
}
async function markDoseTaken(options: {
medicationId: number;
blisterIdx: number;
doseDateOnlyMs: number;
takenAtMs: number;
personSuffix?: string;
}) {
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
args: [1, doseId, Math.floor(takenAtMs / 1000)],
});
}
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: { startDate, endDate },
});
expect(response.statusCode).toBe(200);
const rows = response.json();
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
expect(row).toBeDefined();
return row;
}
function toDateOnlyMs(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
describe("Stock semantics parity (planner usage vs scheduler)", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(medicationRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
await clearTables();
await seedAnonymousUser();
});
it("keeps automatic mode current stock in sync", async () => {
await setStockMode("automatic");
const medName = "Auto Sync";
await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(usageRow.totalPills);
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
await setStockMode("manual");
const medName = "Manual Sync";
await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(10);
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
await setStockMode("manual");
const medName = "Manual Correction";
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
const medicationId = await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
lastStockCorrectionAt: correctionMs,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
await markDoseTaken({
medicationId,
blisterIdx: 0,
doseDateOnlyMs: jan5DateOnly,
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
});
await markDoseTaken({
medicationId,
blisterIdx: 0,
doseDateOnlyMs: jan6DateOnly,
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("counts early taken dose in automatic mode without drift", async () => {
await setStockMode("automatic");
const medName = "Early Taken";
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
tomorrow.setHours(20, 0, 0, 0);
const medicationId = await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
});
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
await markDoseTaken({
medicationId,
blisterIdx: 0,
doseDateOnlyMs: tomorrowDateOnly,
takenAtMs: now.getTime(),
});
const rangeStart = new Date(now);
rangeStart.setDate(now.getDate() - 1);
const rangeEnd = new Date(now);
rangeEnd.setDate(now.getDate() + 7);
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(9);
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("handles mixed intake-level and fallback takenBy consistently", async () => {
await setStockMode("automatic");
const medName = "Mixed TakenBy";
await createMedication({
name: medName,
packCount: 2,
blistersPerPack: 1,
pillsPerBlister: 10,
takenBy: ["Alice", "Bob"],
intakes: [
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
],
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
expect(usageRow.currentPills).toBeLessThan(20);
});
it("excludes obsolete medications from planner usage and scheduler", async () => {
await setStockMode("automatic");
await createMedication({
name: "Obsolete Med",
isObsolete: true,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
});
expect(response.statusCode).toBe(200);
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
});
it("uses generic name fallback in scheduler reminders when commercial name is empty", async () => {
await setStockMode("automatic");
await createMedication({
name: "",
genericName: "Acetylsalicylic acid",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Acetylsalicylic acid")).toBe(true);
});
});
describe("getLiquidReminderThresholds", () => {
// Import the function for testing (test-only export)
// The function is: getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number }
// Formula: lowDays = baselineDays, criticalDays = ceil(lowDays / 2)
it("derives critical as ceil(baseline / 2) for typical baseline", () => {
// For baseline=7 days: low=7, critical=ceil(7/2)=4
const baseline = 7;
// Manually apply the formula to verify
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(7);
expect(expectedCritical).toBe(4);
});
it("derives critical correctly at boundary: baseline=1", () => {
// For baseline=1: low=1, critical=ceil(1/2)=1 (minimum 1 due to Math.max(1, ...))
const baseline = 1;
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(1);
expect(expectedCritical).toBe(1);
});
it("derives thresholds correctly for even baseline (baseline=14)", () => {
// For baseline=14: low=14, critical=ceil(14/2)=7
const baseline = 14;
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(14);
expect(expectedCritical).toBe(7);
});
it("derives thresholds correctly for odd baseline (baseline=15)", () => {
// For baseline=15: low=15, critical=ceil(15/2)=8
const baseline = 15;
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(15);
expect(expectedCritical).toBe(8);
});
});