Files
medassist-ng/backend/src/test/doses.test.ts
T
Daniel Volz 055c0dfe10 feat: Add Clear Missed Doses feature (#28)
- Add dismissed column to dose_tracking table schema
- Add POST /doses/dismiss endpoint for batch dismissing
- Add DELETE /doses/dismiss endpoint to un-dismiss all
- Add frontend dismissedDoses state and missedPastDoseIds useMemo
- Add Clear missed button with confirmation dialog
- Add CSS styles for .past-days-header and .clear-missed-btn
- Add i18n translations for en/de
- Add 5 tests for dismiss endpoints
- Update test schemas with dismissed column

Allows users to acknowledge missed doses without deducting stock.
Closes #28
2026-01-16 21:56:35 +01:00

501 lines
15 KiB
TypeScript

/**
* Tests for /doses/taken API endpoints.
* Tests marking doses as taken, listing taken doses, and unmarking.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
// =============================================================================
// Route Registration
// Since we can't easily import routes that depend on the global db,
// we'll create simplified route handlers for testing the core logic.
// =============================================================================
async function registerDoseRoutes(ctx: TestContext) {
const { app, client } = ctx;
// GET /doses/taken - List all taken doses
app.get("/doses/taken", async (request, reply) => {
// In test mode, use user ID 1 (will be created in tests)
const userId = 1;
const result = await client.execute({
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
return {
doses: result.rows.map((d) => ({
doseId: d.dose_id,
takenAt: (d.taken_at as number) * 1000, // Convert to ms
markedBy: d.marked_by,
})),
};
});
// POST /doses/taken - Mark a dose as taken
app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => {
const userId = 1;
const { doseId } = request.body || {};
if (!doseId || typeof doseId !== "string" || doseId.length === 0) {
return reply.status(400).send({ error: "doseId is required" });
}
// Check if already marked
const existing = await client.execute({
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
return { success: true, message: "Already marked" };
}
// Insert new record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`,
args: [userId, doseId],
});
return { success: true };
});
// DELETE /doses/taken/:doseId - Unmark a dose
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, reply) => {
const userId = 1;
const { doseId } = request.params;
await client.execute({
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
return { success: true };
});
// POST /doses/dismiss - Dismiss missed doses without deducting stock
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
const userId = 1;
const { doseIds } = request.body || {};
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
return reply.status(400).send({ error: "doseIds array is required" });
}
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
// Update to dismissed if not already
if (!existing.rows[0].dismissed) {
await client.execute({
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
args: [existing.rows[0].id],
});
dismissedCount++;
}
} else {
// Insert new dismissed record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
args: [userId, doseId],
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Dose Tracking API", () => {
let ctx: TestContext;
let userId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerDoseRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user - will get ID 1 since table is cleared
userId = await createTestUser(ctx.client, { username: "testuser" });
// Reset SQLite autoincrement so user gets ID 1
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
await clearTestData(ctx.client);
userId = await createTestUser(ctx.client, { username: "testuser" });
});
// ---------------------------------------------------------------------------
// POST /doses/taken
// ---------------------------------------------------------------------------
describe("POST /doses/taken", () => {
it("should mark a dose as taken", async () => {
const doseId = "1-0-1735344000000";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dose_id).toBe(doseId);
expect(result.rows[0].marked_by).toBeNull();
});
it("should return idempotent response when dose already marked", async () => {
const doseId = "1-0-1735344000000";
// Mark once
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Mark again
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Already marked" });
// Should still only have one record
const result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
expect(result.rows[0].count).toBe(1);
});
it("should reject request without doseId", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
});
it("should reject request with empty doseId", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: "" },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
});
});
// ---------------------------------------------------------------------------
// GET /doses/taken
// ---------------------------------------------------------------------------
describe("GET /doses/taken", () => {
it("should return empty array when no doses taken", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ doses: [] });
});
it("should return list of taken doses", async () => {
const doseId1 = "1-0-1735344000000";
const doseId2 = "1-0-1735430400000";
// Mark two doses
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId1 },
});
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId2 },
});
const response = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(2);
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
// Each dose should have a takenAt timestamp
for (const dose of data.doses) {
expect(dose.takenAt).toBeTypeOf("number");
expect(dose.takenAt).toBeGreaterThan(0);
expect(dose.markedBy).toBeNull();
}
});
it("should include markedBy when present", async () => {
const doseId = "1-0-1735344000000";
// Insert directly with markedBy
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, doseId, "Daniel"],
});
const response = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(1);
expect(data.doses[0].markedBy).toBe("Daniel");
});
});
// ---------------------------------------------------------------------------
// DELETE /doses/taken/:doseId
// ---------------------------------------------------------------------------
describe("DELETE /doses/taken/:doseId", () => {
it("should unmark a dose", async () => {
const doseId = "1-0-1735344000000";
// Mark first
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Verify marked
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].count).toBe(1);
// Unmark
const response = await ctx.app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify unmarked
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].count).toBe(0);
});
it("should succeed even if dose was not marked", async () => {
const doseId = "nonexistent-dose-id";
const response = await ctx.app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
});
// ---------------------------------------------------------------------------
// Dose ID Format Tests
// ---------------------------------------------------------------------------
describe("Dose ID Format", () => {
it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => {
const doseId = "5-0-1735344000000";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => {
const doseId = "5-0-1735344000000-Daniel";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle special characters in dose ID", async () => {
// Dose ID with URL-unsafe characters (edge case)
const doseId = "5-0-1735344000000-Max Müller";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
// Can retrieve it
const getResponse = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
});
// ---------------------------------------------------------------------------
// Dismiss Doses Tests (POST /doses/dismiss)
// ---------------------------------------------------------------------------
describe("POST /doses/dismiss", () => {
it("should dismiss multiple doses", async () => {
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
args: [userId],
});
expect(result.rows.length).toBe(2);
});
it("should not double-count already dismissed doses", async () => {
const doseId = "1-0-1735344000000";
// Dismiss once
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Dismiss again
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
});
it("should reject empty doseIds array", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [] },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should reject missing doseIds", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
const doseId = "1-0-1735344000000";
// First mark as taken
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Then dismiss it
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
// Verify it's now dismissed
const result = await ctx.client.execute({
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
expect(result.rows[0].dismissed).toBe(1);
});
});
});