82b2be48cd
* feat: Add Medication Refill feature with UI improvements - Add refill functionality to medications (add packs/loose pills) - Add refill API endpoint with history tracking - Add refill section in edit forms (desktop & mobile) - Add refill modal in medication detail view - Add refill history display with expand/collapse - Add schedule lightbox for clicking medication images - Improve button styling with primary/info/success classes - Move '+ New entry' button to medication list header - Lightbox size: 50% desktop, 90% mobile - Update selectedMed sync after stock changes - Migrate from schema-sql.ts to Drizzle Kit migrations * fix: Improve mobile tooltips and refill modal layout - Center tooltips on screen for mobile devices (fixed position) - Close tooltips automatically when scrolling on touch devices - Use click-based tooltip activation instead of hover on mobile - Fix refill modal buttons to display in two rows on mobile
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
/**
|
||
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
|
||
* Tests adding refills to medication stock and retrieving refill history.
|
||
*/
|
||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||
import {
|
||
buildTestApp,
|
||
closeTestApp,
|
||
clearTestData,
|
||
createTestUser,
|
||
createTestMedication,
|
||
TestContext,
|
||
} from "./setup.js";
|
||
|
||
// Store userId at module level so routes can access it
|
||
let currentUserId = 1;
|
||
|
||
// =============================================================================
|
||
// Route Registration
|
||
// =============================================================================
|
||
|
||
async function registerRefillRoutes(ctx: TestContext) {
|
||
const { app, client } = ctx;
|
||
|
||
// POST /medications/:id/refill - Add stock and record history
|
||
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
|
||
"/medications/:id/refill",
|
||
async (request, reply) => {
|
||
const userId = currentUserId;
|
||
const medId = parseInt(request.params.id, 10);
|
||
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
|
||
|
||
// Validate input
|
||
if (packsAdded < 0 || loosePillsAdded < 0) {
|
||
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
|
||
}
|
||
if (packsAdded === 0 && loosePillsAdded === 0) {
|
||
return reply.status(400).send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
|
||
}
|
||
|
||
// Check medication exists and belongs to user
|
||
const medResult = await client.execute({
|
||
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
|
||
FROM medications WHERE id = ? AND user_id = ?`,
|
||
args: [medId, userId],
|
||
});
|
||
|
||
if (medResult.rows.length === 0) {
|
||
return reply.status(404).send({ error: "Medication not found" });
|
||
}
|
||
|
||
const med = medResult.rows[0];
|
||
const newPackCount = (med.pack_count as number) + packsAdded;
|
||
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
|
||
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
|
||
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
|
||
|
||
// Update medication stock
|
||
await client.execute({
|
||
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
|
||
args: [newPackCount, newLooseTablets, medId],
|
||
});
|
||
|
||
// Record refill history
|
||
await client.execute({
|
||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
|
||
VALUES (?, ?, ?, ?)`,
|
||
args: [medId, userId, packsAdded, loosePillsAdded],
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
pillsAdded: totalPillsAdded,
|
||
newPackCount,
|
||
newLooseTablets,
|
||
};
|
||
}
|
||
);
|
||
|
||
// GET /medications/:id/refills - Get refill history
|
||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
|
||
const userId = currentUserId;
|
||
const medId = parseInt(request.params.id, 10);
|
||
|
||
// Check medication exists and belongs to user
|
||
const medResult = await client.execute({
|
||
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
||
args: [medId, userId],
|
||
});
|
||
|
||
if (medResult.rows.length === 0) {
|
||
return reply.status(404).send({ error: "Medication not found" });
|
||
}
|
||
|
||
// Get refill history, newest first
|
||
const refillResult = await client.execute({
|
||
sql: `SELECT id, packs_added, loose_pills_added, refill_date
|
||
FROM refill_history
|
||
WHERE medication_id = ? AND user_id = ?
|
||
ORDER BY refill_date DESC`,
|
||
args: [medId, userId],
|
||
});
|
||
|
||
return {
|
||
refills: refillResult.rows.map((r) => ({
|
||
id: r.id,
|
||
packsAdded: r.packs_added,
|
||
loosePillsAdded: r.loose_pills_added,
|
||
refillDate: r.refill_date,
|
||
})),
|
||
};
|
||
});
|
||
}
|
||
|
||
// =============================================================================
|
||
// Tests
|
||
// =============================================================================
|
||
|
||
describe("Refill API", () => {
|
||
let ctx: TestContext;
|
||
let userId: number;
|
||
let medId: number;
|
||
|
||
beforeAll(async () => {
|
||
ctx = await buildTestApp();
|
||
await registerRefillRoutes(ctx);
|
||
await ctx.app.ready();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeTestApp(ctx);
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await clearTestData(ctx.client);
|
||
// Create test user
|
||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||
// Update the module-level userId so routes use the correct one
|
||
currentUserId = userId;
|
||
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
|
||
medId = await createTestMedication(ctx.client, {
|
||
userId,
|
||
name: "Test Med",
|
||
packCount: 1,
|
||
blistersPerPack: 10,
|
||
pillsPerBlister: 10,
|
||
looseTablets: 5,
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// POST /medications/:id/refill
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("POST /medications/:id/refill", () => {
|
||
it("should add packs to medication stock", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 2 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.success).toBe(true);
|
||
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
|
||
expect(data.newPackCount).toBe(3); // 1 + 2
|
||
|
||
// Verify in database
|
||
const result = await ctx.client.execute({
|
||
sql: `SELECT pack_count FROM medications WHERE id = ?`,
|
||
args: [medId],
|
||
});
|
||
expect(result.rows[0].pack_count).toBe(3);
|
||
});
|
||
|
||
it("should add loose pills to medication stock", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { loosePillsAdded: 15 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.success).toBe(true);
|
||
expect(data.pillsAdded).toBe(15);
|
||
expect(data.newLooseTablets).toBe(20); // 5 + 15
|
||
|
||
// Verify in database
|
||
const result = await ctx.client.execute({
|
||
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
|
||
args: [medId],
|
||
});
|
||
expect(result.rows[0].loose_tablets).toBe(20);
|
||
});
|
||
|
||
it("should add both packs and loose pills", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.success).toBe(true);
|
||
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
|
||
expect(data.newPackCount).toBe(2);
|
||
expect(data.newLooseTablets).toBe(15);
|
||
});
|
||
|
||
it("should record refill in history", async () => {
|
||
await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 2, loosePillsAdded: 5 },
|
||
});
|
||
|
||
// Check history
|
||
const result = await ctx.client.execute({
|
||
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
|
||
args: [medId],
|
||
});
|
||
expect(result.rows.length).toBe(1);
|
||
expect(result.rows[0].packs_added).toBe(2);
|
||
expect(result.rows[0].loose_pills_added).toBe(5);
|
||
});
|
||
|
||
it("should reject refill with zero amounts", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
expect(response.json().error).toContain("At least one");
|
||
});
|
||
|
||
it("should reject refill with negative amounts", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: -1 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
expect(response.json().error).toContain("non-negative");
|
||
});
|
||
|
||
it("should return 404 for non-existent medication", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/99999/refill`,
|
||
payload: { packsAdded: 1 },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(404);
|
||
expect(response.json().error).toBe("Medication not found");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GET /medications/:id/refills
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("GET /medications/:id/refills", () => {
|
||
it("should return empty array when no refills", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "GET",
|
||
url: `/medications/${medId}/refills`,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
expect(response.json()).toEqual({ refills: [] });
|
||
});
|
||
|
||
it("should return refill history newest first", async () => {
|
||
// Add two refills with different values so we can identify them
|
||
await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||
});
|
||
|
||
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
|
||
await new Promise((r) => setTimeout(r, 1100));
|
||
|
||
await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 0, loosePillsAdded: 20 },
|
||
});
|
||
|
||
const response = await ctx.app.inject({
|
||
method: "GET",
|
||
url: `/medications/${medId}/refills`,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
const data = response.json();
|
||
expect(data.refills).toHaveLength(2);
|
||
|
||
// Newest first (loose pills - added second)
|
||
expect(data.refills[0].packsAdded).toBe(0);
|
||
expect(data.refills[0].loosePillsAdded).toBe(20);
|
||
|
||
// Older (packs - added first)
|
||
expect(data.refills[1].packsAdded).toBe(1);
|
||
expect(data.refills[1].loosePillsAdded).toBe(0);
|
||
|
||
// Each entry should have an id and refillDate
|
||
for (const refill of data.refills) {
|
||
expect(refill.id).toBeTypeOf("number");
|
||
expect(refill.refillDate).toBeTruthy();
|
||
}
|
||
});
|
||
|
||
it("should return 404 for non-existent medication", async () => {
|
||
const response = await ctx.app.inject({
|
||
method: "GET",
|
||
url: `/medications/99999/refills`,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(404);
|
||
expect(response.json().error).toBe("Medication not found");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cascade Delete Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Cascade Delete", () => {
|
||
it("should delete refill history when medication is deleted", async () => {
|
||
// Add a refill
|
||
await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1 },
|
||
});
|
||
|
||
// Verify refill exists
|
||
let result = await ctx.client.execute({
|
||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||
args: [medId],
|
||
});
|
||
expect(result.rows[0].count).toBe(1);
|
||
|
||
// Delete medication
|
||
await ctx.client.execute({
|
||
sql: `DELETE FROM medications WHERE id = ?`,
|
||
args: [medId],
|
||
});
|
||
|
||
// Verify refill history was cascade deleted
|
||
result = await ctx.client.execute({
|
||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||
args: [medId],
|
||
});
|
||
expect(result.rows[0].count).toBe(0);
|
||
});
|
||
|
||
it("should delete refill history when user is deleted", async () => {
|
||
// Add a refill
|
||
await ctx.app.inject({
|
||
method: "POST",
|
||
url: `/medications/${medId}/refill`,
|
||
payload: { packsAdded: 1 },
|
||
});
|
||
|
||
// Verify refill exists
|
||
let result = await ctx.client.execute({
|
||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||
args: [userId],
|
||
});
|
||
expect(result.rows[0].count).toBe(1);
|
||
|
||
// Delete user
|
||
await ctx.client.execute({
|
||
sql: `DELETE FROM users WHERE id = ?`,
|
||
args: [userId],
|
||
});
|
||
|
||
// Verify refill history was cascade deleted
|
||
result = await ctx.client.execute({
|
||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||
args: [userId],
|
||
});
|
||
expect(result.rows[0].count).toBe(0);
|
||
});
|
||
});
|
||
});
|