feat: add comprehensive test suite and CI pipeline

- 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
This commit is contained in:
Daniel Volz
2025-12-30 11:14:52 +01:00
parent fe9310d3d4
commit ba3ebd27f4
27 changed files with 12666 additions and 401 deletions
+672
View File
@@ -0,0 +1,672 @@
/**
* Tests for /medications API endpoints.
* Tests CRUD operations for medications.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
// =============================================================================
// Route Registration
// =============================================================================
async function registerMedicationRoutes(ctx: TestContext) {
const { app, client } = ctx;
// GET /medications - List all medications
app.get("/medications", async (request, reply) => {
const userId = 1;
const result = await client.execute({
sql: `SELECT * FROM medications WHERE user_id = ? ORDER BY name`,
args: [userId],
});
return result.rows.map((m) => ({
id: m.id,
name: m.name,
genericName: m.generic_name,
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
packCount: m.pack_count,
blistersPerPack: m.blisters_per_pack,
pillsPerBlister: m.pills_per_blister,
looseTablets: m.loose_tablets,
pillWeightMg: m.pill_weight_mg,
imageUrl: m.image_url,
expiryDate: m.expiry_date,
notes: m.notes,
intakeRemindersEnabled: Boolean(m.intake_reminders_enabled),
blisters: (() => {
const usage: number[] = JSON.parse((m.usage_json as string) || "[]");
const every: number[] = JSON.parse((m.every_json as string) || "[]");
const start: string[] = JSON.parse((m.start_json as string) || "[]");
return usage.map((u, i) => ({
usage: u,
every: every[i] || 1,
start: start[i] || new Date().toISOString(),
}));
})(),
}));
});
// POST /medications - Create medication
app.post<{
Body: {
name: string;
genericName?: string;
takenBy?: string[];
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
looseTablets?: number;
pillWeightMg?: number;
expiryDate?: string;
notes?: string;
intakeRemindersEnabled?: boolean;
blisters: Array<{ usage: number; every: number; start: string }>;
};
}>("/medications", async (request, reply) => {
const userId = 1;
const body = request.body || {};
// Validation
if (!body.name || body.name.length === 0) {
return reply.status(400).send({ error: "Name is required" });
}
if (body.name.length > 100) {
return reply.status(400).send({ error: "Name must be 100 characters or less" });
}
if (!body.blisters || body.blisters.length === 0) {
return reply.status(400).send({ error: "At least one intake schedule is required" });
}
if (body.blisters.length > 12) {
return reply.status(400).send({ error: "Maximum 12 intake schedules allowed" });
}
const usageJson = JSON.stringify(body.blisters.map((b) => b.usage));
const everyJson = JSON.stringify(body.blisters.map((b) => b.every));
const startJson = JSON.stringify(body.blisters.map((b) => b.start));
const takenByJson = JSON.stringify(body.takenBy || []);
const result = await client.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
pill_weight_mg, expiry_date, notes, intake_reminders_enabled,
usage_json, every_json, start_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
userId,
body.name,
body.genericName || null,
takenByJson,
body.packCount ?? 1,
body.blistersPerPack ?? 1,
body.pillsPerBlister ?? 1,
body.looseTablets ?? 0,
body.pillWeightMg ?? null,
body.expiryDate || null,
body.notes || null,
body.intakeRemindersEnabled ? 1 : 0,
usageJson,
everyJson,
startJson,
],
});
return { id: result.rows[0].id, success: true };
});
// PUT /medications/:id - Update medication
app.put<{
Params: { id: string };
Body: {
name: string;
genericName?: string;
takenBy?: string[];
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
looseTablets?: number;
pillWeightMg?: number;
expiryDate?: string;
notes?: string;
intakeRemindersEnabled?: boolean;
blisters: Array<{ usage: number; every: number; start: string }>;
};
}>("/medications/:id", async (request, reply) => {
const userId = 1;
const medId = parseInt(request.params.id, 10);
const body = request.body || {};
// Check ownership
const existing = await client.execute({
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (existing.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
// Validation
if (!body.name || body.name.length === 0) {
return reply.status(400).send({ error: "Name is required" });
}
if (!body.blisters || body.blisters.length === 0) {
return reply.status(400).send({ error: "At least one intake schedule is required" });
}
const usageJson = JSON.stringify(body.blisters.map((b) => b.usage));
const everyJson = JSON.stringify(body.blisters.map((b) => b.every));
const startJson = JSON.stringify(body.blisters.map((b) => b.start));
const takenByJson = JSON.stringify(body.takenBy || []);
await client.execute({
sql: `UPDATE medications SET
name = ?, generic_name = ?, taken_by_json = ?,
pack_count = ?, blisters_per_pack = ?, pills_per_blister = ?, loose_tablets = ?,
pill_weight_mg = ?, expiry_date = ?, notes = ?, intake_reminders_enabled = ?,
usage_json = ?, every_json = ?, start_json = ?,
updated_at = strftime('%s','now')
WHERE id = ? AND user_id = ?`,
args: [
body.name,
body.genericName || null,
takenByJson,
body.packCount ?? 1,
body.blistersPerPack ?? 1,
body.pillsPerBlister ?? 1,
body.looseTablets ?? 0,
body.pillWeightMg ?? null,
body.expiryDate || null,
body.notes || null,
body.intakeRemindersEnabled ? 1 : 0,
usageJson,
everyJson,
startJson,
medId,
userId,
],
});
return { success: true };
});
// DELETE /medications/:id - Delete medication
app.delete<{ Params: { id: string } }>("/medications/:id", async (request, reply) => {
const userId = 1;
const medId = parseInt(request.params.id, 10);
// Check ownership
const existing = await client.execute({
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (existing.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
await client.execute({
sql: `DELETE FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
return { success: true };
});
// GET /medications/:id - Get single medication
app.get<{ Params: { id: string } }>("/medications/:id", async (request, reply) => {
const userId = 1;
const medId = parseInt(request.params.id, 10);
const result = await client.execute({
sql: `SELECT * FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (result.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
const m = result.rows[0];
return {
id: m.id,
name: m.name,
genericName: m.generic_name,
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
packCount: m.pack_count,
blistersPerPack: m.blisters_per_pack,
pillsPerBlister: m.pills_per_blister,
looseTablets: m.loose_tablets,
pillWeightMg: m.pill_weight_mg,
imageUrl: m.image_url,
expiryDate: m.expiry_date,
notes: m.notes,
intakeRemindersEnabled: Boolean(m.intake_reminders_enabled),
blisters: (() => {
const usage: number[] = JSON.parse((m.usage_json as string) || "[]");
const every: number[] = JSON.parse((m.every_json as string) || "[]");
const start: string[] = JSON.parse((m.start_json as string) || "[]");
return usage.map((u, i) => ({
usage: u,
every: every[i] || 1,
start: start[i] || new Date().toISOString(),
}));
})(),
};
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Medications API", () => {
let ctx: TestContext;
let userId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerMedicationRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='medications'");
userId = await createTestUser(ctx.client, { username: "testuser" });
});
// ---------------------------------------------------------------------------
// GET /medications
// ---------------------------------------------------------------------------
describe("GET /medications", () => {
it("should return empty array when no medications", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/medications",
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([]);
});
it("should return list of medications", async () => {
await createTestMedication(ctx.client, {
userId,
name: "Aspirin",
genericName: "Acetylsalicylic acid",
takenBy: ["Daniel"],
packCount: 2,
pillsPerBlister: 10,
});
await createTestMedication(ctx.client, {
userId,
name: "Ibuprofen",
});
const response = await ctx.app.inject({
method: "GET",
url: "/medications",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data).toHaveLength(2);
// Sorted by name
expect(data[0].name).toBe("Aspirin");
expect(data[0].genericName).toBe("Acetylsalicylic acid");
expect(data[0].takenBy).toEqual(["Daniel"]);
expect(data[1].name).toBe("Ibuprofen");
});
it("should return medication with all fields", async () => {
const startDate = "2025-01-01T08:00:00.000Z";
await createTestMedication(ctx.client, {
userId,
name: "Test Med",
genericName: "Generic Name",
takenBy: ["Person1", "Person2"],
packCount: 3,
blistersPerPack: 2,
pillsPerBlister: 14,
looseTablets: 5,
pillWeightMg: 500,
blisters: [
{ usage: 1, every: 1, start: startDate },
{ usage: 2, every: 2, start: startDate },
],
});
const response = await ctx.app.inject({
method: "GET",
url: "/medications",
});
expect(response.statusCode).toBe(200);
const [med] = response.json();
expect(med.name).toBe("Test Med");
expect(med.genericName).toBe("Generic Name");
expect(med.takenBy).toEqual(["Person1", "Person2"]);
expect(med.packCount).toBe(3);
expect(med.blistersPerPack).toBe(2);
expect(med.pillsPerBlister).toBe(14);
expect(med.looseTablets).toBe(5);
expect(med.pillWeightMg).toBe(500);
expect(med.blisters).toHaveLength(2);
expect(med.blisters[0]).toEqual({ usage: 1, every: 1, start: startDate });
expect(med.blisters[1]).toEqual({ usage: 2, every: 2, start: startDate });
});
});
// ---------------------------------------------------------------------------
// POST /medications
// ---------------------------------------------------------------------------
describe("POST /medications", () => {
it("should create a medication", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/medications",
payload: {
name: "New Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.id).toBeDefined();
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT name FROM medications WHERE id = ?`,
args: [data.id],
});
expect(result.rows[0].name).toBe("New Med");
});
it("should create medication with all fields", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Full Med",
genericName: "Generic",
takenBy: ["Alice", "Bob"],
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
pillWeightMg: 250,
expiryDate: "2026-12-31",
notes: "Take with food",
intakeRemindersEnabled: true,
blisters: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
{ usage: 2, every: 1, start: "2025-01-01T20:00:00.000Z" },
],
},
});
expect(response.statusCode).toBe(200);
// Verify
const medId = response.json().id;
const result = await ctx.client.execute({
sql: `SELECT * FROM medications WHERE id = ?`,
args: [medId],
});
const med = result.rows[0];
expect(med.name).toBe("Full Med");
expect(med.generic_name).toBe("Generic");
expect(JSON.parse(med.taken_by_json as string)).toEqual(["Alice", "Bob"]);
expect(med.pack_count).toBe(2);
expect(med.blisters_per_pack).toBe(3);
expect(med.pills_per_blister).toBe(10);
expect(med.loose_tablets).toBe(5);
expect(med.pill_weight_mg).toBe(250);
expect(med.expiry_date).toBe("2026-12-31");
expect(med.notes).toBe("Take with food");
expect(med.intake_reminders_enabled).toBe(1);
});
it("should reject request without name", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/medications",
payload: {
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Name is required");
});
it("should reject request without blisters", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Test",
blisters: [],
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("At least one intake schedule is required");
});
it("should reject name over 100 characters", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/medications",
payload: {
name: "A".repeat(101),
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Name must be 100 characters or less");
});
it("should reject more than 12 blisters", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Test",
blisters: Array(13).fill({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }),
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Maximum 12 intake schedules allowed");
});
});
// ---------------------------------------------------------------------------
// PUT /medications/:id
// ---------------------------------------------------------------------------
describe("PUT /medications/:id", () => {
it("should update a medication", async () => {
const medId = await createTestMedication(ctx.client, {
userId,
name: "Old Name",
});
const response = await ctx.app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "New Name",
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify
const result = await ctx.client.execute({
sql: `SELECT name, usage_json FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].name).toBe("New Name");
expect(JSON.parse(result.rows[0].usage_json as string)).toEqual([2]);
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/medications/99999",
payload: {
name: "Test",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
it("should not update medication of another user", async () => {
// Create another user
const otherUserId = await createTestUser(ctx.client, { username: "other" });
const medId = await createTestMedication(ctx.client, {
userId: otherUserId,
name: "Other Med",
});
const response = await ctx.app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Hacked",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(response.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// DELETE /medications/:id
// ---------------------------------------------------------------------------
describe("DELETE /medications/:id", () => {
it("should delete a medication", async () => {
const medId = await createTestMedication(ctx.client, {
userId,
name: "To Delete",
});
const response = await ctx.app.inject({
method: "DELETE",
url: `/medications/${medId}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify deleted
const result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(0);
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "DELETE",
url: "/medications/99999",
});
expect(response.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// GET /medications/:id
// ---------------------------------------------------------------------------
describe("GET /medications/:id", () => {
it("should return single medication", async () => {
const medId = await createTestMedication(ctx.client, {
userId,
name: "Single Med",
genericName: "Generic",
takenBy: ["Daniel"],
});
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}`,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.id).toBe(medId);
expect(data.name).toBe("Single Med");
expect(data.genericName).toBe("Generic");
expect(data.takenBy).toEqual(["Daniel"]);
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/medications/99999",
});
expect(response.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// Stock Calculation Tests
// ---------------------------------------------------------------------------
describe("Stock Calculation", () => {
it("should calculate total pills correctly", async () => {
await createTestMedication(ctx.client, {
userId,
name: "Stock Test",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
});
const response = await ctx.app.inject({
method: "GET",
url: "/medications",
});
const [med] = response.json();
// Total = (2 packs × 3 blisters × 10 pills) + 5 loose = 65
const totalPills =
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
expect(totalPills).toBe(65);
});
});
});