mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
226 lines
7.3 KiB
TypeScript
226 lines
7.3 KiB
TypeScript
/**
|
|
* v1.4.16 — integration coverage for POST /api/insights/feedback.
|
|
*
|
|
* Three scenarios pinned end-to-end against the postgres testcontainer:
|
|
* 1. POST a valid payload → 201, row persisted with server-filled
|
|
* providerType (from the latest insights.generate audit row) +
|
|
* promptVersion (from the PROMPT_VERSION constant). Audit log
|
|
* entry written with action `insights.recommendation.feedback`.
|
|
* 2. Re-POST the same (recId, recText) → 409, no second row written.
|
|
* 3. Validation failure (bad severity) → 422, no row written.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function seedUserWithAuditedGeneration(): Promise<{
|
|
userId: string;
|
|
}> {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "feedback-user",
|
|
email: "feedback@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
// Seed a recent insights.generate audit row so the feedback endpoint
|
|
// can pull `chainProviderType` from it for provider attribution.
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: user.id,
|
|
action: "insights.generate",
|
|
details: JSON.stringify({
|
|
privacyMode: "aggregated",
|
|
providerType: "openai",
|
|
chainProviderType: "codex",
|
|
fallbackHopCount: 0,
|
|
tokensUsed: 1234,
|
|
model: "gpt-4o-mini",
|
|
}),
|
|
},
|
|
});
|
|
|
|
return { userId: user.id };
|
|
}
|
|
|
|
const baseBody = {
|
|
recommendationId: "rec-1",
|
|
recommendationText: "Discuss home BP log with your physician.",
|
|
recommendationSeverity: "important",
|
|
metricSourceType: "bloodPressure",
|
|
metricSourceTimeRange: "last7days",
|
|
helpful: true,
|
|
};
|
|
|
|
interface FeedbackEnvelope {
|
|
data: { id: string; createdAt: string } | null;
|
|
error?: string | null;
|
|
}
|
|
|
|
describe("POST /api/insights/feedback — integration", () => {
|
|
it("persists a valid feedback row with server-filled provider attribution", async () => {
|
|
const { userId } = await seedUserWithAuditedGeneration();
|
|
const { POST } = await import("@/app/api/insights/feedback/route");
|
|
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/feedback", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(baseBody),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const env = (await res.json()) as FeedbackEnvelope;
|
|
expect(env.data?.id).toBeTruthy();
|
|
|
|
const prisma = getPrismaClient();
|
|
const rows = await prisma.recommendationFeedback.findMany({
|
|
where: { userId },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
const row = rows[0];
|
|
expect(row.recommendationId).toBe(baseBody.recommendationId);
|
|
expect(row.recommendationText).toBe(baseBody.recommendationText);
|
|
expect(row.recommendationSeverity).toBe(baseBody.recommendationSeverity);
|
|
expect(row.metricSourceType).toBe(baseBody.metricSourceType);
|
|
expect(row.metricSourceTimeRange).toBe(baseBody.metricSourceTimeRange);
|
|
expect(row.helpful).toBe(true);
|
|
// Server fills providerType + promptVersion — the body never
|
|
// supplied them.
|
|
expect(row.providerType).toBe("codex");
|
|
expect(row.promptVersion).toBe(PROMPT_VERSION);
|
|
|
|
// Audit row was written with the right action.
|
|
const audit = await prisma.auditLog.findFirst({
|
|
where: { userId, action: "insights.recommendation.feedback" },
|
|
});
|
|
expect(audit).not.toBeNull();
|
|
});
|
|
|
|
it("rejects a duplicate (recId, recText) with 409", async () => {
|
|
const { userId } = await seedUserWithAuditedGeneration();
|
|
const { POST } = await import("@/app/api/insights/feedback/route");
|
|
|
|
const first = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/feedback", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(baseBody),
|
|
}),
|
|
);
|
|
expect(first.status).toBe(201);
|
|
|
|
const second = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/feedback", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(baseBody),
|
|
}),
|
|
);
|
|
expect(second.status).toBe(409);
|
|
|
|
const prisma = getPrismaClient();
|
|
expect(
|
|
await prisma.recommendationFeedback.count({ where: { userId } }),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("rejects a bad severity with 422 and writes no row", async () => {
|
|
const { userId } = await seedUserWithAuditedGeneration();
|
|
const { POST } = await import("@/app/api/insights/feedback/route");
|
|
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/feedback", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ ...baseBody, recommendationSeverity: "wat" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
|
|
const prisma = getPrismaClient();
|
|
expect(
|
|
await prisma.recommendationFeedback.count({ where: { userId } }),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("falls back to providerType=unknown when no insights.generate audit row exists", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "fallback-user",
|
|
email: "fallback-feedback@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const { POST } = await import("@/app/api/insights/feedback/route");
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/feedback", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(baseBody),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
|
|
const row = await prisma.recommendationFeedback.findFirst({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(row?.providerType).toBe("unknown");
|
|
expect(row?.promptVersion).toBe(PROMPT_VERSION);
|
|
});
|
|
});
|