mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
test(coach): cover the module gate on the chat and about-me routes
Per-route tests assert the two-layer Coach module gate: a disabled module (operator availability OR per-user disableCoach) yields 403 module.disabled before any Coach work runs, and an enabled module falls through to the normal path. Covers the chat POST + list GET, about-me GET/PUT, the questions GET/DELETE, and the adopt POST short-circuit ahead of the locked read-modify-write.
This commit is contained in:
1 parent
064ed92071
commit
85b0b635f2
4 files changed
+440
-1
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the about-me self-context GET/PUT enforce the two-layer
|
||||
* Coach module gate (operator availability AND the per-user
|
||||
* `disableCoach` opt-out) right after auth. A disabled module returns
|
||||
* the 403 `module.disabled` envelope before any self-context read or
|
||||
* write; an enabled module proceeds to the normal 200 path.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
}));
|
||||
|
||||
const { requireModuleEnabled } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
getClientIp: () => "127.0.0.1",
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
error: "validation",
|
||||
status,
|
||||
}),
|
||||
safeJson: async (req: Request) => ({ data: await req.json(), error: null }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
userHealthProfile: {
|
||||
findUnique: vi.fn(async () => ({ updatedAt: new Date(0) })),
|
||||
upsert: vi.fn(async () => ({ updatedAt: new Date(0) })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => Buffer.from(s),
|
||||
}));
|
||||
const emptyCtx = {
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
};
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextForUser: vi.fn(async () => emptyCtx),
|
||||
getPendingQuestionsForUser: vi.fn(async () => []),
|
||||
setPendingQuestionsForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
deriveClarifyingQuestions: vi.fn(async () => ({
|
||||
questions: [],
|
||||
source: "none",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
const get = GET as unknown as () => Promise<Envelope>;
|
||||
const put = PUT as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const disabledResponse: Envelope = {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function putReq(): Request {
|
||||
return new Request("http://localhost/api/coach/about-me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aboutMe: "I run daily" }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("about-me module gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await get();
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
});
|
||||
|
||||
it("GET returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("PUT returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await put(putReq());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
});
|
||||
|
||||
it("PUT returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await put(putReq());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,12 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
getClientIp: () => "127.0.0.1",
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
@@ -62,9 +67,11 @@ vi.mock("@/lib/api-response", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const transaction = prisma.$transaction as ReturnType<typeof vi.fn>;
|
||||
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
|
||||
const gate = requireModuleEnabled as ReturnType<typeof vi.fn>;
|
||||
|
||||
function adoptRequest(body: { question?: string; answer: string }): Request {
|
||||
return new Request("http://localhost/api/coach/about-me/adopt", {
|
||||
@@ -90,6 +97,29 @@ describe("POST /api/coach/about-me/adopt", () => {
|
||||
tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" });
|
||||
tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() });
|
||||
tx.$queryRaw.mockResolvedValue([{ id: "p1" }]);
|
||||
gate.mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
// v1.18.0 — Coach module gate (operator availability OR per-user
|
||||
// disableCoach) short-circuits before the locked read-modify-write.
|
||||
it("returns 403 module.disabled when Coach is disabled, without a write", async () => {
|
||||
gate.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "evening walks" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(gate).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the read-modify-write inside one transaction with a row lock", async () => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the clarifying-questions GET/DELETE enforce the two-layer
|
||||
* Coach module gate (operator availability AND the per-user
|
||||
* `disableCoach` opt-out) right after auth. A disabled module returns
|
||||
* the 403 `module.disabled` envelope before any pending-question read or
|
||||
* mutation; an enabled module proceeds to the normal 200 path.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
const { requireModuleEnabled } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
error: "validation",
|
||||
status,
|
||||
}),
|
||||
safeJson: async () => ({ data: {}, error: null }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getPendingQuestionsForUser: vi.fn(async () => ["q1"]),
|
||||
setPendingQuestionsForUser: vi.fn(),
|
||||
PENDING_QUESTION_MAX_CHARS: 200,
|
||||
}));
|
||||
|
||||
import { GET, DELETE } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
const get = GET as unknown as () => Promise<Envelope>;
|
||||
const del = DELETE as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const disabledResponse: Envelope = {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function delReq(): Request {
|
||||
return new Request("http://localhost/api/coach/about-me/questions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("about-me questions module gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await get();
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
});
|
||||
|
||||
it("GET returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("DELETE returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await del(delReq());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
});
|
||||
|
||||
it("DELETE returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await del(delReq());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the Coach chat POST (SSE) and the conversation-list GET both
|
||||
* enforce the two-layer module gate (operator availability AND the
|
||||
* per-user `disableCoach` opt-out) right after auth. A disabled module
|
||||
* returns the 403 `module.disabled` envelope verbatim before any other
|
||||
* Coach dependency runs; an enabled module falls through to the existing
|
||||
* assistant-flag check. The suite pins both branches so a refactor can't
|
||||
* quietly drop the gate.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// The legacy assistant-flag gate the handler reaches AFTER the module
|
||||
// gate. We throw a sentinel so an enabled module is observable by the
|
||||
// control reaching this call.
|
||||
class AssistantSentinel extends Error {}
|
||||
|
||||
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
requireAssistantSurface: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
|
||||
|
||||
// Everything else the module imports — stubbed harmlessly; none of it is
|
||||
// reached on the disabled branch, and the enabled branch stops at the
|
||||
// assistant sentinel above.
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/coach/types", () => ({
|
||||
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
listConversations: vi.fn(async () => ({ conversations: [], nextCursor: null })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(),
|
||||
enforceBudget: vi.fn(),
|
||||
recordSpend: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel: vi.fn() }));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
const post = POST as unknown as (req: Request) => Promise<Envelope>;
|
||||
const get = GET as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const disabledResponse: Envelope = {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function chatPostReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "hi" }),
|
||||
});
|
||||
}
|
||||
|
||||
function listReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", { method: "GET" });
|
||||
}
|
||||
|
||||
describe("coach chat module gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAssistantSurface.mockImplementation(async () => {
|
||||
throw new AssistantSentinel("reached assistant surface");
|
||||
});
|
||||
});
|
||||
|
||||
it("POST returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
|
||||
const res = await post(chatPostReq());
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
// The gate short-circuits before the legacy assistant flag.
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST falls through to the assistant flag when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
|
||||
await expect(post(chatPostReq())).rejects.toBeInstanceOf(AssistantSentinel);
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
});
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
|
||||
const res = await get(listReq());
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET reaches 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
requireAssistantSurface.mockResolvedValue(undefined);
|
||||
|
||||
const res = await get(listReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user