/** * Tests for share link API endpoints. * Tests creating share tokens, accessing shared schedules, and marking doses via share links. */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { buildTestApp, closeTestApp, clearTestData, createTestUser, createTestMedication, createTestShareToken, TestContext, } from "./setup.js"; // ============================================================================= // Route Registration // ============================================================================= async function registerShareRoutes(ctx: TestContext) { const { app, client } = ctx; // POST /share - Create a share token app.post<{ Body: { takenBy: string; scheduleDays?: number } }>("/share", async (request, reply) => { const userId = 1; const { takenBy, scheduleDays = 30 } = request.body || {}; if (!takenBy || typeof takenBy !== "string" || takenBy.length === 0) { return reply.status(400).send({ error: "takenBy is required", code: "VALIDATION_ERROR" }); } if (scheduleDays < 1 || scheduleDays > 365) { return reply.status(400).send({ error: "scheduleDays must be 1-365", code: "VALIDATION_ERROR" }); } // Check if user has medications for this person const meds = await client.execute({ sql: `SELECT id, taken_by_json FROM medications WHERE user_id = ?`, args: [userId], }); const hasMatchingMed = meds.rows.some((m) => { const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]"); return takenByList.includes(takenBy); }); if (!hasMatchingMed) { return reply.status(400).send({ error: "No medications found for this person", code: "NO_MEDICATIONS" }); } // Generate token const token = `share_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; const expiresAt = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; // 30 days await client.execute({ sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`, args: [userId, token, takenBy, scheduleDays, expiresAt], }); return { token, shareUrl: `/share/${token}`, expiresAt: new Date(expiresAt * 1000).toISOString(), }; }); // GET /share/:token - Get shared schedule data app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => { const { token } = request.params; const shareResult = await client.execute({ sql: `SELECT st.*, u.username as owner_username FROM share_tokens st JOIN users u ON st.user_id = u.id WHERE st.token = ?`, args: [token], }); if (shareResult.rows.length === 0) { return reply.status(404).send({ error: "Share link not found", code: "NOT_FOUND" }); } const share = shareResult.rows[0]; const now = Math.floor(Date.now() / 1000); // Check expiry if (share.expires_at && (share.expires_at as number) < now) { return reply.status(410).send({ error: "Share link has expired", code: "EXPIRED", ownerUsername: share.owner_username, takenBy: share.taken_by, expiredAt: new Date((share.expires_at as number) * 1000).toISOString(), }); } // Get medications for this person const medsResult = await client.execute({ sql: `SELECT * FROM medications WHERE user_id = ?`, args: [share.user_id], }); const medications = medsResult.rows .filter((m) => { const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]"); return takenByList.includes(share.taken_by as string); }) .map((m) => { const usageArr: number[] = JSON.parse(m.usage_json as string || "[]"); const everyArr: number[] = JSON.parse(m.every_json as string || "[]"); const startArr: string[] = JSON.parse(m.start_json as string || "[]"); return { id: m.id, name: m.name, genericName: m.generic_name, pillWeightMg: m.pill_weight_mg, imageUrl: m.image_url, totalPills: (m.pack_count as number) * (m.blisters_per_pack as number) * (m.pills_per_blister as number) + (m.loose_tablets as number), packCount: m.pack_count, blistersPerPack: m.blisters_per_pack, looseTablets: m.loose_tablets, pillsPerBlister: m.pills_per_blister, takenBy: JSON.parse(m.taken_by_json as string || "[]"), blisters: usageArr.map((usage, i) => ({ usage, every: everyArr[i] || 1, start: startArr[i] || new Date().toISOString(), })), }; }); // Get settings const settingsResult = await client.execute({ sql: `SELECT low_stock_days FROM user_settings WHERE user_id = ?`, args: [share.user_id], }); const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30; return { takenBy: share.taken_by, sharedBy: share.owner_username, scheduleDays: share.schedule_days, medications, stockThresholds: { lowStockDays, }, }; }); // GET /share/:token/doses - Get taken doses for share link app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => { const { token } = request.params; const shareResult = await client.execute({ sql: `SELECT user_id FROM share_tokens WHERE token = ?`, args: [token], }); if (shareResult.rows.length === 0) { return reply.status(404).send({ error: "Share link not found" }); } const userId = shareResult.rows[0].user_id; const dosesResult = await client.execute({ sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`, args: [userId], }); return { doses: dosesResult.rows.map((d) => ({ doseId: d.dose_id, takenAt: (d.taken_at as number) * 1000, markedBy: d.marked_by, })), }; }); // POST /share/:token/doses - Mark dose via share link app.post<{ Params: { token: string }; Body: { doseId: string } }>( "/share/:token/doses", async (request, reply) => { const { token } = request.params; const { doseId } = request.body || {}; if (!doseId) { return reply.status(400).send({ error: "doseId is required" }); } const shareResult = await client.execute({ sql: `SELECT user_id, taken_by FROM share_tokens WHERE token = ?`, args: [token], }); if (shareResult.rows.length === 0) { return reply.status(404).send({ error: "Share link not found" }); } const { user_id: userId, taken_by: takenBy } = shareResult.rows[0]; // 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 with markedBy = takenBy from share token await client.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, args: [userId, doseId, takenBy], }); return { success: true }; } ); // DELETE /share/:token/doses/:doseId - Unmark dose via share link app.delete<{ Params: { token: string; doseId: string } }>( "/share/:token/doses/:doseId", async (request, reply) => { const { token, doseId } = request.params; const shareResult = await client.execute({ sql: `SELECT user_id FROM share_tokens WHERE token = ?`, args: [token], }); if (shareResult.rows.length === 0) { return reply.status(404).send({ error: "Share link not found" }); } const userId = shareResult.rows[0].user_id; await client.execute({ sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`, args: [userId, doseId], }); return { success: true }; } ); // GET /share/people - Get unique takenBy values app.get("/share/people", async (request, reply) => { const userId = 1; const result = await client.execute({ sql: `SELECT taken_by_json FROM medications WHERE user_id = ?`, args: [userId], }); const peopleSet = new Set(); for (const row of result.rows) { const takenByList: string[] = JSON.parse(row.taken_by_json as string || "[]"); takenByList.forEach((p) => peopleSet.add(p)); } return { people: Array.from(peopleSet).sort() }; }); } // ============================================================================= // Tests // ============================================================================= describe("Share Link API", () => { let ctx: TestContext; let userId: number; beforeAll(async () => { ctx = await buildTestApp(); await registerShareRoutes(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" }); }); // --------------------------------------------------------------------------- // POST /share - Create share token // --------------------------------------------------------------------------- describe("POST /share", () => { it("should create a share token for a person", async () => { // Create medication with takenBy await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Daniel"], }); const response = await ctx.app.inject({ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 30 }, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.token).toBeDefined(); expect(data.token.length).toBeGreaterThan(10); expect(data.shareUrl).toBe(`/share/${data.token}`); expect(data.expiresAt).toBeDefined(); }); it("should reject when no medications for person", async () => { // Create medication with different takenBy await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Max"], }); const response = await ctx.app.inject({ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 30 }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "No medications found for this person", code: "NO_MEDICATIONS", }); }); it("should reject request without takenBy", async () => { const response = await ctx.app.inject({ method: "POST", url: "/share", payload: { scheduleDays: 30 }, }); expect(response.statusCode).toBe(400); expect(response.json()).toEqual({ error: "takenBy is required", code: "VALIDATION_ERROR", }); }); it("should use custom scheduleDays", async () => { await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Daniel"], }); const response = await ctx.app.inject({ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 90 }, }); expect(response.statusCode).toBe(200); // Verify in DB const token = response.json().token; const result = await ctx.client.execute({ sql: `SELECT schedule_days FROM share_tokens WHERE token = ?`, args: [token], }); expect(result.rows[0].schedule_days).toBe(90); }); }); // --------------------------------------------------------------------------- // GET /share/:token - Access shared schedule // --------------------------------------------------------------------------- describe("GET /share/:token", () => { it("should return shared schedule data", async () => { // Create medication await createTestMedication(ctx.client, { userId, name: "Aspirin", genericName: "Acetylsalicylic acid", takenBy: ["Daniel"], packCount: 2, blistersPerPack: 3, pillsPerBlister: 10, looseTablets: 5, blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }); // Create share token const token = await createTestShareToken(ctx.client, { userId, takenBy: "Daniel", scheduleDays: 30, }); const response = await ctx.app.inject({ method: "GET", url: `/share/${token}`, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.takenBy).toBe("Daniel"); expect(data.sharedBy).toBe("testuser"); expect(data.scheduleDays).toBe(30); expect(data.medications).toHaveLength(1); const med = data.medications[0]; expect(med.name).toBe("Aspirin"); expect(med.genericName).toBe("Acetylsalicylic acid"); expect(med.totalPills).toBe(2 * 3 * 10 + 5); // 65 expect(med.takenBy).toEqual(["Daniel"]); expect(med.blisters).toHaveLength(1); expect(med.blisters[0].usage).toBe(1); expect(med.blisters[0].every).toBe(1); }); it("should return 404 for invalid token", async () => { const response = await ctx.app.inject({ method: "GET", url: "/share/invalid_token_123", }); expect(response.statusCode).toBe(404); expect(response.json()).toEqual({ error: "Share link not found", code: "NOT_FOUND", }); }); it("should return 410 for expired token", async () => { await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Daniel"], }); // Create expired token (expired 1 day ago) const expiredAt = Math.floor(Date.now() / 1000) - 86400; const token = await createTestShareToken(ctx.client, { userId, takenBy: "Daniel", expiresAt: expiredAt, }); const response = await ctx.app.inject({ method: "GET", url: `/share/${token}`, }); expect(response.statusCode).toBe(410); const data = response.json(); expect(data.code).toBe("EXPIRED"); expect(data.ownerUsername).toBe("testuser"); expect(data.takenBy).toBe("Daniel"); }); it("should filter medications to only those for takenBy person", async () => { // Create two medications - one for Daniel, one for Max await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Daniel"], }); await createTestMedication(ctx.client, { userId, name: "Ibuprofen", takenBy: ["Max"], }); const token = await createTestShareToken(ctx.client, { userId, takenBy: "Daniel", }); const response = await ctx.app.inject({ method: "GET", url: `/share/${token}`, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.medications).toHaveLength(1); expect(data.medications[0].name).toBe("Aspirin"); }); }); // --------------------------------------------------------------------------- // Share Token Dose Tracking // --------------------------------------------------------------------------- describe("Share link dose tracking", () => { it("POST /share/:token/doses should mark dose with markedBy", async () => { await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Daniel"], }); const token = await createTestShareToken(ctx.client, { userId, takenBy: "Daniel", }); const doseId = "1-0-1735344000000"; const response = await ctx.app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true }); // Verify markedBy is set to takenBy from share token const result = await ctx.client.execute({ sql: `SELECT marked_by FROM dose_tracking WHERE dose_id = ?`, args: [doseId], }); expect(result.rows[0].marked_by).toBe("Daniel"); }); it("GET /share/:token/doses should return all doses for owner", async () => { await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Daniel"], }); const token = await createTestShareToken(ctx.client, { userId, takenBy: "Daniel", }); // Create some dose tracking records await ctx.client.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, args: [userId, "1-0-1735344000000", null], }); await ctx.client.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`, args: [userId, "1-0-1735430400000", "Daniel"], }); const response = await ctx.app.inject({ method: "GET", url: `/share/${token}/doses`, }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.doses).toHaveLength(2); }); it("DELETE /share/:token/doses/:doseId should unmark dose", async () => { await createTestMedication(ctx.client, { userId, name: "Aspirin", takenBy: ["Daniel"], }); const token = await createTestShareToken(ctx.client, { userId, takenBy: "Daniel", }); const doseId = "1-0-1735344000000"; // Mark dose first await ctx.app.inject({ method: "POST", url: `/share/${token}/doses`, payload: { doseId }, }); // Unmark const response = await ctx.app.inject({ method: "DELETE", url: `/share/${token}/doses/${encodeURIComponent(doseId)}`, }); 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 dose_tracking WHERE dose_id = ?`, args: [doseId], }); expect(result.rows[0].count).toBe(0); }); }); // --------------------------------------------------------------------------- // GET /share/people // --------------------------------------------------------------------------- describe("GET /share/people", () => { it("should return unique takenBy values from all medications", async () => { await createTestMedication(ctx.client, { userId, name: "Med 1", takenBy: ["Daniel", "Max"], }); await createTestMedication(ctx.client, { userId, name: "Med 2", takenBy: ["Daniel", "Lisa"], }); const response = await ctx.app.inject({ method: "GET", url: "/share/people", }); expect(response.statusCode).toBe(200); const data = response.json(); expect(data.people).toEqual(["Daniel", "Lisa", "Max"]); // sorted }); it("should return empty array when no medications", async () => { const response = await ctx.app.inject({ method: "GET", url: "/share/people", }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ people: [] }); }); }); });