Files
medassist-ng/backend/src/test/intake-journal-routes.test.ts
T

454 lines
14 KiB
TypeScript

import { existsSync, unlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import sensible from "@fastify/sensible";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, testDbPath, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-intake-journal-routes-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
testDbPath: dbPath,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
PUBLIC_APP_URL: "https://app.example.com",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { exportRoutes } = await import("../routes/export.js");
const { intakeJournalRoutes } = await import("../routes/intake-journal.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM intake_journal");
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function seedMedication(options: { userId: number; name: string; start?: string; takenBy?: string[] }) {
const start = options.start ?? "2026-02-01T08:00:00.000Z";
const takenBy = options.takenBy ?? ["Daniel"];
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
usage_json, every_json, start_json, intakes_json,
stock_adjustment, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.name,
`${options.name} Generic`,
JSON.stringify(takenBy),
"tablet",
"blister",
1,
1,
10,
0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([
{
usage: 1,
every: 1,
start,
takenBy: takenBy[0] ?? null,
intakeRemindersEnabled: true,
},
]),
0,
1,
],
});
return Number(result.rows[0].id);
}
async function seedTrackedDose(options: {
userId: number;
doseId: string;
takenAt: Date;
markedBy?: string | null;
dismissed?: boolean;
}) {
const result = await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by, taken_source, dismissed)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.doseId,
Math.floor(options.takenAt.getTime() / 1000),
options.markedBy ?? null,
"manual",
options.dismissed ? 1 : 0,
],
});
return Number(result.rows[0].id);
}
describe("Intake journal routes", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
if (existsSync(path)) {
unlinkSync(path);
}
}
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
});
it("keeps journal CRUD/history owner-scoped across route access", async () => {
const ownerId = await createUser("journal-owner");
const otherId = await createUser("journal-other");
const ownerCookie = await buildSessionCookie(app, ownerId, "journal-owner");
const otherCookie = await buildSessionCookie(app, otherId, "journal-other");
const ownerStart = "2026-02-01T08:00:00.000Z";
const otherStart = "2026-02-02T09:00:00.000Z";
const ownerMedicationId = await seedMedication({ userId: ownerId, name: "Owner Med", start: ownerStart });
const otherMedicationId = await seedMedication({ userId: otherId, name: "Other Med", start: otherStart });
const ownerDoseId = `${ownerMedicationId}-0-${new Date(ownerStart).getTime()}-Daniel`;
const otherDoseId = `${otherMedicationId}-0-${new Date(otherStart).getTime()}-Maria`;
await seedTrackedDose({
userId: ownerId,
doseId: ownerDoseId,
takenAt: new Date("2026-02-01T08:05:00.000Z"),
markedBy: "Daniel",
});
await seedTrackedDose({
userId: otherId,
doseId: otherDoseId,
takenAt: new Date("2026-02-02T09:05:00.000Z"),
markedBy: "Maria",
});
const ownerPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
payload: { note: "Took after breakfast." },
});
expect(ownerPutResponse.statusCode).toBe(200);
expect(ownerPutResponse.json().entry).toEqual(
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
scheduledFor: expect.stringContaining("T08:00:00"),
note: "Took after breakfast.",
})
);
const otherPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: otherCookie },
payload: { note: "Different owner note." },
});
expect(otherPutResponse.statusCode).toBe(200);
const ownerHistoryResponse = await app.inject({
method: "GET",
url: `/intake-journal?medicationId=${ownerMedicationId}&limit=25`,
headers: { cookie: ownerCookie },
});
expect(ownerHistoryResponse.statusCode).toBe(200);
expect(ownerHistoryResponse.json().entries).toEqual([
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
note: "Took after breakfast.",
markedBy: "Daniel",
}),
]);
const otherEventResponse = await app.inject({
method: "GET",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(otherEventResponse.statusCode).toBe(404);
expect(otherEventResponse.json()).toMatchObject({ code: "DOSE_NOT_FOUND" });
const deleteResponse = await app.inject({
method: "DELETE",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toEqual({ success: true });
const emptyHistoryResponse = await app.inject({
method: "GET",
url: "/intake-journal",
headers: { cookie: ownerCookie },
});
expect(emptyHistoryResponse.statusCode).toBe(200);
expect(emptyHistoryResponse.json().entries).toEqual([]);
});
it("preserves journal metadata through authenticated export and import", async () => {
const userId = await createUser("journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "journal-roundtrip");
const start = "2026-02-03T07:30:00.000Z";
const medicationId = await seedMedication({ userId, name: "Roundtrip Journal Med", start });
const doseId = `${medicationId}-0-${new Date(start).getTime()}-Daniel`;
const doseTrackingId = await seedTrackedDose({
userId,
doseId,
takenAt: new Date("2026-02-03T07:33:00.000Z"),
markedBy: "Daniel",
});
const createdAt = new Date("2026-02-03T07:40:00.000Z");
const updatedAt = new Date("2026-02-03T07:50:00.000Z");
await testClient.execute({
sql: `INSERT INTO intake_journal (
user_id, dose_tracking_id, medication_id, scheduled_for, note, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
doseTrackingId,
medicationId,
Math.floor(new Date(start).getTime() / 1000),
"Roundtrip journal note",
Math.floor(createdAt.getTime() / 1000),
Math.floor(updatedAt.getTime() / 1000),
],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.doseHistory).toHaveLength(1);
expect(exportBody.doseHistory[0]).toEqual(
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
})
);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const reExportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(reExportResponse.statusCode).toBe(200);
expect(reExportResponse.json().doseHistory).toEqual([
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
}),
]);
const restoredJournalRows = await testClient.execute({
sql: "SELECT note FROM intake_journal WHERE user_id = ?",
args: [userId],
});
expect(restoredJournalRows.rows).toHaveLength(1);
expect(restoredJournalRows.rows[0].note).toBe("Roundtrip journal note");
});
it("preserves the shared journal-note permission through authenticated export and import", async () => {
const userId = await createUser("share-journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "share-journal-roundtrip");
await testClient.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [userId, "share-journal-token", "Daniel", 14, 1, null],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.shareLinks).toEqual([
expect.objectContaining({
takenBy: "Daniel",
scheduleDays: 14,
allowJournalNotes: true,
regenerateToken: true,
}),
]);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const shareRows = await testClient.execute({
sql: "SELECT token, taken_by, schedule_days, allow_journal_notes FROM share_tokens WHERE user_id = ?",
args: [userId],
});
expect(shareRows.rows).toHaveLength(1);
expect(shareRows.rows[0]).toEqual(
expect.objectContaining({
taken_by: "Daniel",
schedule_days: 14,
allow_journal_notes: 1,
})
);
expect(shareRows.rows[0].token).not.toBe("share-journal-token");
});
it("keeps existing data when import fails inside the replacement transaction", async () => {
const userId = await createUser("import-rollback");
const sessionCookie = await buildSessionCookie(app, userId, "import-rollback");
await seedMedication({ userId, name: "Existing Rollback Med" });
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: {
version: "1.6",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Rollback Med",
inventory: { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2026-02-04T08:00:00.000Z" }],
},
],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2026-02-04T08:00:00.000Z",
takenAt: "not-a-date",
},
],
},
});
expect(importResponse.statusCode).toBe(500);
const medicationRows = await testClient.execute({
sql: "SELECT name FROM medications WHERE user_id = ? ORDER BY name",
args: [userId],
});
expect(medicationRows.rows).toEqual([expect.objectContaining({ name: "Existing Rollback Med" })]);
});
});