ba3ebd27f4
- Add 402 unit tests with 61.7% code coverage - Add Vitest configuration with coverage reporting - Extract testable utility functions from services - Create test.yml workflow (runs on PR and push to main) - Update docker-build.yml to require tests before building - Add scheduler-utils.ts and server-config.ts for testable code Test files added: - auth.test.ts, medications.test.ts, planner.test.ts - settings.test.ts, doses.test.ts, share.test.ts - database.test.ts, server.test.ts, services.test.ts - env.test.ts, translations.test.ts, integration.test.ts - e2e-routes.test.ts, stock-calculation.test.ts
636 lines
20 KiB
TypeScript
636 lines
20 KiB
TypeScript
/**
|
||
* Tests for stock calculation modes (automatic vs manual).
|
||
* Tests the /medications/usage endpoint with different settings.
|
||
*/
|
||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||
import {
|
||
buildTestApp,
|
||
closeTestApp,
|
||
clearTestData,
|
||
createTestUser,
|
||
createTestMedication,
|
||
createTestDoseTracking,
|
||
setUserSettings,
|
||
TestContext,
|
||
} from "./setup.js";
|
||
|
||
// =============================================================================
|
||
// Route Registration
|
||
// =============================================================================
|
||
|
||
async function registerUsageRoutes(ctx: TestContext) {
|
||
const { app, client } = ctx;
|
||
|
||
// POST /medications/usage - Calculate medication usage for a date range
|
||
app.post<{ Body: { startDate: string; endDate: string } }>(
|
||
"/medications/usage",
|
||
async (request, reply) => {
|
||
const userId = 1;
|
||
const { startDate, endDate } = request.body || {};
|
||
|
||
if (!startDate || !endDate) {
|
||
return reply.status(400).send({ error: "startDate and endDate are required" });
|
||
}
|
||
|
||
const start = new Date(startDate);
|
||
const end = new Date(endDate);
|
||
|
||
// Get user settings
|
||
const settingsResult = await client.execute({
|
||
sql: `SELECT stock_calculation_mode FROM user_settings WHERE user_id = ?`,
|
||
args: [userId],
|
||
});
|
||
const stockMode =
|
||
settingsResult.rows.length > 0
|
||
? (settingsResult.rows[0].stock_calculation_mode as string)
|
||
: "automatic";
|
||
|
||
// Get all medications
|
||
const medsResult = await client.execute({
|
||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||
args: [userId],
|
||
});
|
||
|
||
const results = [];
|
||
|
||
for (const med of medsResult.rows) {
|
||
const totalPills =
|
||
(med.pack_count as number) *
|
||
(med.blisters_per_pack as number) *
|
||
(med.pills_per_blister as number) +
|
||
(med.loose_tablets as number);
|
||
|
||
const blisterSize = med.pills_per_blister as number;
|
||
|
||
// Calculate usage based on schedule
|
||
const usageArr: number[] = JSON.parse((med.usage_json as string) || "[]");
|
||
const everyArr: number[] = JSON.parse((med.every_json as string) || "[]");
|
||
const startArr: string[] = JSON.parse((med.start_json as string) || "[]");
|
||
|
||
let plannerUsage = 0;
|
||
|
||
if (stockMode === "automatic") {
|
||
// Automatic: Calculate from schedule
|
||
for (let i = 0; i < usageArr.length; i++) {
|
||
const usage = usageArr[i] || 0;
|
||
const every = everyArr[i] || 1;
|
||
const scheduleStart = new Date(startArr[i] || start);
|
||
|
||
// Count doses from scheduleStart to end within the range
|
||
let current = new Date(scheduleStart);
|
||
while (current <= end) {
|
||
if (current >= start) {
|
||
plannerUsage += usage;
|
||
}
|
||
current.setDate(current.getDate() + every);
|
||
}
|
||
}
|
||
} else {
|
||
// Manual: Count only tracked doses in the date range
|
||
const dosesResult = await client.execute({
|
||
sql: `SELECT dose_id FROM dose_tracking
|
||
WHERE user_id = ?
|
||
AND taken_at >= ?
|
||
AND taken_at <= ?`,
|
||
args: [
|
||
userId,
|
||
Math.floor(start.getTime() / 1000),
|
||
Math.floor(end.getTime() / 1000),
|
||
],
|
||
});
|
||
|
||
// Filter to doses for this medication
|
||
const medIdStr = `${med.id}-`;
|
||
for (const dose of dosesResult.rows) {
|
||
const doseId = dose.dose_id as string;
|
||
if (doseId.startsWith(medIdStr)) {
|
||
// Parse usage from the schedule based on blister index
|
||
const parts = doseId.split("-");
|
||
if (parts.length >= 3) {
|
||
const blisterIdx = parseInt(parts[1], 10);
|
||
plannerUsage += usageArr[blisterIdx] || 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Calculate how many blisters/pills needed
|
||
const blistersNeeded = Math.ceil(plannerUsage / blisterSize);
|
||
const fullBlisters = Math.floor(plannerUsage / blisterSize);
|
||
const loosePills = plannerUsage % blisterSize;
|
||
|
||
results.push({
|
||
medicationId: med.id,
|
||
medicationName: med.name,
|
||
totalPills,
|
||
plannerUsage,
|
||
blisterSize,
|
||
blistersNeeded,
|
||
fullBlisters,
|
||
loosePills,
|
||
enough: totalPills >= plannerUsage,
|
||
});
|
||
}
|
||
|
||
return results;
|
||
}
|
||
);
|
||
|
||
// GET /medications - List medications (for checking stock)
|
||
app.get("/medications", async (request, reply) => {
|
||
const userId = 1;
|
||
|
||
const result = await client.execute({
|
||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||
args: [userId],
|
||
});
|
||
|
||
return result.rows.map((m) => ({
|
||
id: m.id,
|
||
name: m.name,
|
||
packCount: m.pack_count,
|
||
blistersPerPack: m.blisters_per_pack,
|
||
pillsPerBlister: m.pills_per_blister,
|
||
looseTablets: m.loose_tablets,
|
||
totalPills:
|
||
(m.pack_count as number) *
|
||
(m.blisters_per_pack as number) *
|
||
(m.pills_per_blister as number) +
|
||
(m.loose_tablets as number),
|
||
}));
|
||
});
|
||
}
|
||
|
||
// =============================================================================
|
||
// Tests
|
||
// =============================================================================
|
||
|
||
describe("Stock Calculation API", () => {
|
||
let ctx: TestContext;
|
||
let userId: number;
|
||
|
||
beforeAll(async () => {
|
||
ctx = await buildTestApp();
|
||
await registerUsageRoutes(ctx);
|
||
await ctx.app.ready();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeTestApp(ctx);
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await clearTestData(ctx.client);
|
||
// Reset SQLite autoincrement so user gets ID 1
|
||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Automatic Mode Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Automatic mode", () => {
|
||
beforeEach(async () => {
|
||
await setUserSettings(ctx.client, {
|
||
userId,
|
||
stockCalculationMode: "automatic",
|
||
});
|
||
});
|
||
|
||
it("should calculate usage from schedule", async () => {
|
||
// Medication: 1 pill daily starting Jan 1
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Aspirin",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
// Calculate usage for 10 days (Jan 1-10)
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data).toHaveLength(1);
|
||
|
||
const med = data[0];
|
||
expect(med.medicationName).toBe("Aspirin");
|
||
expect(med.totalPills).toBe(30);
|
||
expect(med.plannerUsage).toBe(10); // 10 days, 1 pill/day
|
||
expect(med.enough).toBe(true);
|
||
});
|
||
|
||
it("should handle every-other-day schedules", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Med B",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 20,
|
||
blisters: [{ usage: 2, every: 2, start: start.toISOString() }], // 2 pills every 2 days
|
||
});
|
||
|
||
// 10 days: Jan 1, 3, 5, 7, 9 = 5 doses × 2 pills = 10 pills
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].plannerUsage).toBe(10);
|
||
});
|
||
|
||
it("should handle multiple blisters (schedules)", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Multi Schedule",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 50,
|
||
blisters: [
|
||
{ usage: 1, every: 1, start: start.toISOString() }, // Morning: 1/day
|
||
{ usage: 1, every: 1, start: start.toISOString() }, // Evening: 1/day
|
||
],
|
||
});
|
||
|
||
// 10 days: 2 schedules × 10 days × 1 pill = 20 pills
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].plannerUsage).toBe(20);
|
||
});
|
||
|
||
it("should return enough=false when stock insufficient", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Low Stock Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 5, // Only 5 pills
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
// Need 10 pills but only have 5
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].totalPills).toBe(5);
|
||
expect(data[0].plannerUsage).toBe(10);
|
||
expect(data[0].enough).toBe(false);
|
||
});
|
||
|
||
it("should calculate blister counts correctly", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Blister Test",
|
||
packCount: 2,
|
||
blistersPerPack: 2,
|
||
pillsPerBlister: 10, // 4 blisters × 10 = 40 pills
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
// 25 days = 25 pills needed = 2 full blisters + 5 loose
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-25T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].plannerUsage).toBe(25);
|
||
expect(data[0].blisterSize).toBe(10);
|
||
expect(data[0].blistersNeeded).toBe(3); // ceil(25/10)
|
||
expect(data[0].fullBlisters).toBe(2); // floor(25/10)
|
||
expect(data[0].loosePills).toBe(5); // 25 % 10
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Manual Mode Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Manual mode", () => {
|
||
beforeEach(async () => {
|
||
await setUserSettings(ctx.client, {
|
||
userId,
|
||
stockCalculationMode: "manual",
|
||
});
|
||
});
|
||
|
||
it("should count only tracked doses", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
const medId = await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Manual Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
// In automatic mode this would count 10 doses
|
||
// In manual mode, only count tracked doses
|
||
// Track only 3 doses
|
||
const jan2 = Math.floor(new Date("2025-01-02T08:00:00.000Z").getTime() / 1000);
|
||
const jan5 = Math.floor(new Date("2025-01-05T08:00:00.000Z").getTime() / 1000);
|
||
const jan8 = Math.floor(new Date("2025-01-08T08:00:00.000Z").getTime() / 1000);
|
||
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${jan2 * 1000}`,
|
||
takenAt: jan2,
|
||
});
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${jan5 * 1000}`,
|
||
takenAt: jan5,
|
||
});
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${jan8 * 1000}`,
|
||
takenAt: jan8,
|
||
});
|
||
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].plannerUsage).toBe(3); // Only 3 tracked doses
|
||
});
|
||
|
||
it("should return 0 usage when no doses tracked", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Untracked Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
// No dose tracking records
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].plannerUsage).toBe(0);
|
||
expect(data[0].enough).toBe(true);
|
||
});
|
||
|
||
it("should only count doses within date range", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
const medId = await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Range Test",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
// Dose before range (Dec 31)
|
||
const dec31 = Math.floor(new Date("2024-12-31T08:00:00.000Z").getTime() / 1000);
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${dec31 * 1000}`,
|
||
takenAt: dec31,
|
||
});
|
||
|
||
// Dose in range (Jan 5)
|
||
const jan5 = Math.floor(new Date("2025-01-05T08:00:00.000Z").getTime() / 1000);
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${jan5 * 1000}`,
|
||
takenAt: jan5,
|
||
});
|
||
|
||
// Dose after range (Jan 15)
|
||
const jan15 = Math.floor(new Date("2025-01-15T08:00:00.000Z").getTime() / 1000);
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${jan15 * 1000}`,
|
||
takenAt: jan15,
|
||
});
|
||
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].plannerUsage).toBe(1); // Only Jan 5 is in range
|
||
});
|
||
|
||
it("should handle multi-pill doses correctly", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
const medId = await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Multi-Pill",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 2, every: 1, start: start.toISOString() }], // 2 pills per dose
|
||
});
|
||
|
||
const jan2 = Math.floor(new Date("2025-01-02T08:00:00.000Z").getTime() / 1000);
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${jan2 * 1000}`, // Blister index 0 has usage=2
|
||
takenAt: jan2,
|
||
});
|
||
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data[0].plannerUsage).toBe(2); // 1 dose × 2 pills
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mode Comparison Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Automatic vs Manual mode comparison", () => {
|
||
it("should show different results for same medication", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
const medId = await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Comparison Med",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 30,
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
// Track only 5 of the 10 scheduled doses
|
||
for (let day = 1; day <= 5; day++) {
|
||
const date = new Date(`2025-01-0${day}T08:00:00.000Z`);
|
||
const ts = Math.floor(date.getTime() / 1000);
|
||
await createTestDoseTracking(ctx.client, {
|
||
userId,
|
||
doseId: `${medId}-0-${ts * 1000}`,
|
||
takenAt: ts,
|
||
});
|
||
}
|
||
|
||
// Test automatic mode
|
||
await setUserSettings(ctx.client, {
|
||
userId,
|
||
stockCalculationMode: "automatic",
|
||
});
|
||
|
||
const autoResponse = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(autoResponse.statusCode).toBe(200);
|
||
const autoData = autoResponse.json();
|
||
expect(autoData[0].plannerUsage).toBe(10); // Schedule says 10 doses
|
||
|
||
// Test manual mode
|
||
await setUserSettings(ctx.client, {
|
||
userId,
|
||
stockCalculationMode: "manual",
|
||
});
|
||
|
||
const manualResponse = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(manualResponse.statusCode).toBe(200);
|
||
const manualData = manualResponse.json();
|
||
expect(manualData[0].plannerUsage).toBe(5); // Only 5 actually tracked
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Multiple Medications Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Multiple medications", () => {
|
||
it("should calculate usage for all medications", async () => {
|
||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Med A",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 20,
|
||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||
});
|
||
|
||
await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Med B",
|
||
packCount: 1,
|
||
blistersPerPack: 1,
|
||
pillsPerBlister: 20,
|
||
blisters: [{ usage: 2, every: 2, start: start.toISOString() }],
|
||
});
|
||
|
||
await setUserSettings(ctx.client, {
|
||
userId,
|
||
stockCalculationMode: "automatic",
|
||
});
|
||
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: "/medications/usage",
|
||
payload: {
|
||
startDate: "2025-01-01T00:00:00.000Z",
|
||
endDate: "2025-01-10T23:59:59.999Z",
|
||
},
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data).toHaveLength(2);
|
||
|
||
const medA = data.find((d: any) => d.medicationName === "Med A");
|
||
const medB = data.find((d: any) => d.medicationName === "Med B");
|
||
|
||
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
||
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
||
});
|
||
});
|
||
});
|