mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(api): /api/insights/feedback persists thumbs with provider attribution
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.
Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.
The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.
Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.
Tests:
- 7 unit tests for the Zod request schema (valid path, severity
poisoning, time-range poisoning, empty fields, stringified bool,
extra-field stripping)
- 5 unit tests for `pickProviderType()` priority + fallback
- 4 integration tests: 201 happy path with provider attribution,
409 dedup, 422 validation, fallback to unknown when no audit row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
badc893853
commit
8255e3e49a
6 files changed
+591
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { resolveFeedbackAttribution } from "@/lib/ai/feedback-attribution";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5e — POST /api/insights/feedback.
|
||||
*
|
||||
* Per-recommendation thumbs-up / thumbs-down. The body contains the
|
||||
* rec id + snapshot text + severity + metric-source attributes the
|
||||
* client just rendered. Server-side fills providerType + promptVersion
|
||||
* via `resolveFeedbackAttribution()` so the client cannot tamper with
|
||||
* the slice the daily aggregator computes.
|
||||
*
|
||||
* The endpoint is idempotent via `withIdempotency()` — a retry with
|
||||
* the same `Idempotency-Key` for the same `(userId, method, path)`
|
||||
* replays the cached response with `X-Idempotent-Replay: true`.
|
||||
*
|
||||
* 201 on insert; 409 on the `(userId, recommendationId,
|
||||
* recommendationText)` unique violation (the user already rated this
|
||||
* exact rec text). 422 on a validation failure.
|
||||
*/
|
||||
async function handlePost(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error } = await safeJson<unknown>(request);
|
||||
if (error) return error;
|
||||
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return apiError("Invalid feedback payload", 422);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const attribution = await resolveFeedbackAttribution(user.id);
|
||||
|
||||
try {
|
||||
const created = await prisma.recommendationFeedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
recommendationId: body.recommendationId,
|
||||
recommendationText: body.recommendationText,
|
||||
recommendationSeverity: body.recommendationSeverity,
|
||||
metricSourceType: body.metricSourceType,
|
||||
metricSourceTimeRange: body.metricSourceTimeRange,
|
||||
helpful: body.helpful,
|
||||
providerType: attribution.providerType,
|
||||
promptVersion: attribution.promptVersion,
|
||||
},
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
await auditLog("insights.recommendation.feedback", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: created.id,
|
||||
recommendationId: body.recommendationId,
|
||||
severity: body.recommendationSeverity,
|
||||
helpful: body.helpful,
|
||||
providerType: attribution.providerType,
|
||||
promptVersion: attribution.promptVersion,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
meta: {
|
||||
outcome: "created",
|
||||
severity: body.recommendationSeverity,
|
||||
helpful: body.helpful,
|
||||
providerType: attribution.providerType,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ id: created.id, createdAt: created.createdAt }, 201);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
meta: { outcome: "already_rated" },
|
||||
});
|
||||
return apiError("already_rated", 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency(handlePost));
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
FEEDBACK_ATTRIBUTION_FALLBACK,
|
||||
pickProviderType,
|
||||
} from "../feedback-attribution";
|
||||
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
||||
|
||||
describe("pickProviderType", () => {
|
||||
it("prefers chainProviderType when present", () => {
|
||||
// chainProviderType captures the runner's chosen entry across the
|
||||
// multi-provider chain (B5b). When a fallback fires, the working
|
||||
// provider differs from the user's primary — the feedback row must
|
||||
// reflect the working provider, not the configured one.
|
||||
const got = pickProviderType({
|
||||
chainProviderType: "openai",
|
||||
providerType: "codex",
|
||||
});
|
||||
expect(got).toBe("openai");
|
||||
});
|
||||
|
||||
it("falls back to providerType when chainProviderType is missing", () => {
|
||||
const got = pickProviderType({ providerType: "codex" });
|
||||
expect(got).toBe("codex");
|
||||
});
|
||||
|
||||
it("falls back to 'unknown' when neither field is present", () => {
|
||||
const got = pickProviderType({});
|
||||
expect(got).toBe("unknown");
|
||||
});
|
||||
|
||||
it("ignores non-string values", () => {
|
||||
const got = pickProviderType({
|
||||
chainProviderType: 123 as unknown as string,
|
||||
providerType: { name: "evil" } as unknown as string,
|
||||
});
|
||||
expect(got).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FEEDBACK_ATTRIBUTION_FALLBACK", () => {
|
||||
it("uses 'unknown' for the providerType so the aggregator groups separately", () => {
|
||||
expect(FEEDBACK_ATTRIBUTION_FALLBACK.providerType).toBe("unknown");
|
||||
});
|
||||
|
||||
it("snapshots the current PROMPT_VERSION so the row is persistable", () => {
|
||||
expect(FEEDBACK_ATTRIBUTION_FALLBACK.promptVersion).toBe(PROMPT_VERSION);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* v1.4.16 phase B5e — server-side resolver for the provider/prompt
|
||||
* attribution that gets stamped on every `RecommendationFeedback` row.
|
||||
*
|
||||
* The client cannot supply these fields — that would let any user
|
||||
* poison the daily aggregator's slice (`(severity x provider x prompt)
|
||||
* helpful-rate`) by claiming a thumbs-down was attributed to a rival
|
||||
* provider. So the API endpoint reads the user's most recent
|
||||
* `insights.generate` audit row, pulls `chainProviderType` (or its
|
||||
* legacy fallback `providerType`) plus the current `PROMPT_VERSION`
|
||||
* constant, and stamps those on the row.
|
||||
*
|
||||
* If no audit row exists yet (the user is rating a per-status insight
|
||||
* that pre-dates the comprehensive generate flow), we fall back to
|
||||
* "unknown" + the current PROMPT_VERSION so the row is still
|
||||
* persistable and the aggregator can group "unknown" separately.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
||||
|
||||
export interface FeedbackAttribution {
|
||||
providerType: string;
|
||||
promptVersion: string;
|
||||
}
|
||||
|
||||
/** Default attribution when no `insights.generate` row exists yet. */
|
||||
export const FEEDBACK_ATTRIBUTION_FALLBACK: Readonly<FeedbackAttribution> = {
|
||||
providerType: "unknown",
|
||||
promptVersion: PROMPT_VERSION,
|
||||
};
|
||||
|
||||
interface AuditDetailsShape {
|
||||
providerType?: unknown;
|
||||
chainProviderType?: unknown;
|
||||
}
|
||||
|
||||
function parseAuditDetails(raw: string | null): AuditDetailsShape {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as AuditDetailsShape;
|
||||
} catch {
|
||||
// Treat unparseable details as absent — the fallback applies.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper exported for unit tests so we can pin the priority order
|
||||
* (chainProviderType > providerType > "unknown") without hitting the
|
||||
* DB. The current PROMPT_VERSION constant is always returned because
|
||||
* the generator itself doesn't yet stash a per-payload prompt version
|
||||
* (research §3.B notes this is a v1.4.17 ratchet item; reading the
|
||||
* constant snapshots whatever's deployed when the rating fires).
|
||||
*/
|
||||
export function pickProviderType(details: AuditDetailsShape): string {
|
||||
if (typeof details.chainProviderType === "string") {
|
||||
return details.chainProviderType;
|
||||
}
|
||||
if (typeof details.providerType === "string") return details.providerType;
|
||||
return FEEDBACK_ATTRIBUTION_FALLBACK.providerType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the (providerType, promptVersion) tuple to stamp on a
|
||||
* `RecommendationFeedback` row. Reads the latest `insights.generate`
|
||||
* audit row for the user. Falls back to the safe defaults when no
|
||||
* audit row exists.
|
||||
*/
|
||||
export async function resolveFeedbackAttribution(
|
||||
userId: string,
|
||||
): Promise<FeedbackAttribution> {
|
||||
const latest = await prisma.auditLog.findFirst({
|
||||
where: { userId, action: "insights.generate" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { details: true },
|
||||
});
|
||||
|
||||
if (!latest) return { ...FEEDBACK_ATTRIBUTION_FALLBACK };
|
||||
|
||||
const details = parseAuditDetails(latest.details);
|
||||
return {
|
||||
providerType: pickProviderType(details),
|
||||
promptVersion: PROMPT_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { recommendationFeedbackRequestSchema } from "../recommendation-feedback";
|
||||
|
||||
describe("recommendationFeedbackRequestSchema", () => {
|
||||
const baseValid = {
|
||||
recommendationId: "rec-1",
|
||||
recommendationText: "Discuss home BP log with your physician.",
|
||||
recommendationSeverity: "important" as const,
|
||||
metricSourceType: "bloodPressure",
|
||||
metricSourceTimeRange: "last7days" as const,
|
||||
helpful: true,
|
||||
};
|
||||
|
||||
it("accepts a valid payload", () => {
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse(baseValid);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown severity (defence against poisoning the aggregator)", () => {
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse({
|
||||
...baseValid,
|
||||
recommendationSeverity: "wat",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown time-range value", () => {
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse({
|
||||
...baseValid,
|
||||
metricSourceTimeRange: "lastFortnight",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty recommendation id", () => {
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse({
|
||||
...baseValid,
|
||||
recommendationId: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty recommendation text", () => {
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse({
|
||||
...baseValid,
|
||||
recommendationText: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-boolean helpful field (catches stringified-boolean clients)", () => {
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse({
|
||||
...baseValid,
|
||||
helpful: "true",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects extra fields when stripping isn't desired (helpful is the only thumbs verb)", () => {
|
||||
// Zod default behaviour: passthrough is OFF; unknown keys are
|
||||
// dropped. We don't use .strict() so a future field can be added
|
||||
// additively, but the parsed value must not carry the extra field.
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse({
|
||||
...baseValid,
|
||||
providerType: "codex",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect("providerType" in parsed.data).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5e — request body for `POST /api/insights/feedback`.
|
||||
*
|
||||
* Mirrors the `RecommendationFeedback` Prisma model fields the client
|
||||
* is allowed to specify. `providerType` and `promptVersion` are NOT
|
||||
* part of the request shape — the server fills them from the user's
|
||||
* most recent cached payload + the current PROMPT_VERSION constant
|
||||
* so the client can't tamper with the attribution that the daily
|
||||
* aggregator slices on for the admin AI quality dashboard.
|
||||
*
|
||||
* Severity + dataWindow are constrained to the same vocabulary as the
|
||||
* AI schema (`aiRecommendationSchema` / `aiRecommendationRationaleSchema`)
|
||||
* so a bogus rating attempt with `severity = "wat"` is rejected at the
|
||||
* edge instead of poisoning the aggregator.
|
||||
*/
|
||||
export const recommendationFeedbackSeverity = z.enum([
|
||||
"info",
|
||||
"suggestion",
|
||||
"important",
|
||||
"urgent",
|
||||
]);
|
||||
|
||||
export const recommendationFeedbackTimeRange = z.enum([
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
]);
|
||||
|
||||
export const recommendationFeedbackRequestSchema = z.object({
|
||||
/**
|
||||
* Stable per-payload rec id. Dedup happens on
|
||||
* (userId, recommendationId, recommendationText).
|
||||
*/
|
||||
recommendationId: z.string().min(1).max(200),
|
||||
/**
|
||||
* Snapshot of the rec text at submission time. Dedup partner so a
|
||||
* regeneration that rewrites the same id with new text counts as a
|
||||
* different row — that's intentional, the aggregator wants per-text
|
||||
* signal not per-id signal.
|
||||
*/
|
||||
recommendationText: z.string().min(1).max(2000),
|
||||
recommendationSeverity: recommendationFeedbackSeverity,
|
||||
metricSourceType: z.string().min(1).max(80),
|
||||
metricSourceTimeRange: recommendationFeedbackTimeRange,
|
||||
helpful: z.boolean(),
|
||||
});
|
||||
|
||||
export type RecommendationFeedbackRequest = z.infer<
|
||||
typeof recommendationFeedbackRequestSchema
|
||||
>;
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* v1.4.16 phase B5e — 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);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user