fix: unify stock semantics across planner and scheduler (#245)
* fix: unify stock semantics across planner and scheduler * fix: stabilize dashboard hmr and align stock helper tests
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
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,
|
||||
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, 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,
|
||||
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 });
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user