mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
1 parent
b3fea2ef59
commit
1f4ba5939d
1000 files changed
+12130
-8385
No files matched your search
@@ -268,7 +268,10 @@ function fileHasCall(text: string, needle: string): boolean {
|
||||
* parameterised insights routes resolve it from a metric map — those gate
|
||||
* dynamically and are accepted as gated without a literal-key assertion).
|
||||
*/
|
||||
function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set<string> } {
|
||||
function extractModuleKeys(text: string): {
|
||||
hasLiteral: boolean;
|
||||
keys: Set<string>;
|
||||
} {
|
||||
const keys = new Set<string>();
|
||||
let hasLiteral = false;
|
||||
const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g;
|
||||
|
||||
@@ -120,14 +120,14 @@ describe("GET /api/admin/ai-settings", () => {
|
||||
|
||||
describe("PUT /api/admin/ai-settings", () => {
|
||||
function upsertEcho() {
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation(
|
||||
(async (args: { update: Record<string, unknown> }) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never,
|
||||
);
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation((async (args: {
|
||||
update: Record<string, unknown>;
|
||||
}) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never);
|
||||
}
|
||||
|
||||
it("propagates the admin gate (non-admin cannot write)", async () => {
|
||||
|
||||
@@ -81,22 +81,28 @@ beforeEach(() => {
|
||||
describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when a non-admin cookie session calls the route", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_SESSION as never);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -107,11 +113,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
"content-type": "multipart/form-data; boundary=---x",
|
||||
"content-length": String(2 * 1024 * 1024 * 1024),
|
||||
});
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
@@ -119,11 +128,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 429 on rate-limit exhaustion", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
@@ -106,10 +106,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "target_user_missing", targetUserId },
|
||||
});
|
||||
return apiError(
|
||||
`Target user '${targetUserId}' does not exist`,
|
||||
422,
|
||||
);
|
||||
return apiError(`Target user '${targetUserId}' does not exist`, 422);
|
||||
}
|
||||
|
||||
// Same content-hash idempotency as the user-facing route, but scoped
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import {
|
||||
buildInviteUrl,
|
||||
generateInviteToken,
|
||||
} from "@/lib/auth/invite-token";
|
||||
import { buildInviteUrl, generateInviteToken } from "@/lib/auth/invite-token";
|
||||
import { inviteCreateSchema } from "@/lib/validations/invite";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -77,9 +74,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
|
||||
const { expiresInDays, maxUses } = parsed.data;
|
||||
const rawToken = generateInviteToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const invite = await prisma.inviteToken.create({
|
||||
data: {
|
||||
|
||||
@@ -109,9 +109,7 @@ interface DiagnosticEnvelope {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
|
||||
@@ -80,9 +80,10 @@ interface DiagnosticPayload {
|
||||
* (prefix / suffix both null) — that shape never appears in
|
||||
* production, but defensive against partial inserts during testing.
|
||||
*/
|
||||
function maskApnsToken(
|
||||
token: string | null,
|
||||
): { prefix: string | null; suffix: string | null } {
|
||||
function maskApnsToken(token: string | null): {
|
||||
prefix: string | null;
|
||||
suffix: string | null;
|
||||
} {
|
||||
if (!token || token.length < 16) return { prefix: null, suffix: null };
|
||||
return {
|
||||
prefix: token.slice(0, 8),
|
||||
|
||||
@@ -75,7 +75,13 @@ export const GET = apiHandler(async (request) => {
|
||||
for (const row of grouped) {
|
||||
const existing =
|
||||
byChannel.get(row.channel) ??
|
||||
({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
|
||||
({
|
||||
channel: row.channel,
|
||||
ok: 0,
|
||||
error: 0,
|
||||
skipped: 0,
|
||||
total: 0,
|
||||
} satisfies ChannelHealthRow);
|
||||
const count = row._count._all;
|
||||
if (row.result === "ok") existing.ok += count;
|
||||
else if (row.result === "error") existing.error += count;
|
||||
|
||||
@@ -36,8 +36,9 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -149,9 +150,7 @@ describe("POST /api/admin/notifications/test — APNS branch (v1.4.46)", () => {
|
||||
eventType: "SYSTEM_ALERT",
|
||||
}),
|
||||
);
|
||||
expect(body.data.results).toEqual([
|
||||
{ channel: "APNS", success: true },
|
||||
]);
|
||||
expect(body.data.results).toEqual([{ channel: "APNS", success: true }]);
|
||||
expect(body.data.sent).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,8 +63,14 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return value === "de" || value === "en" || value === "fr" ||
|
||||
value === "es" || value === "it" || value === "pl";
|
||||
return (
|
||||
value === "de" ||
|
||||
value === "en" ||
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
|
||||
@@ -48,7 +48,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
@@ -56,7 +58,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_OK as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -65,7 +69,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
@@ -82,7 +88,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
moduleAvailabilityJson: { mood: false },
|
||||
} as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
@@ -68,7 +73,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const alreadyConfigured = Boolean(
|
||||
existing?.webPushVapidPublicKey &&
|
||||
existing?.webPushVapidPrivateKeyEncrypted,
|
||||
existing?.webPushVapidPrivateKeyEncrypted,
|
||||
);
|
||||
|
||||
if (alreadyConfigured && parsed.data.force !== true) {
|
||||
|
||||
@@ -170,5 +170,4 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
|
||||
});
|
||||
@@ -97,7 +97,10 @@ describe("PUT /api/admin/users/[id]", () => {
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
|
||||
const res = await PUT(
|
||||
jsonReq({ username: "testuser" }),
|
||||
params("u-missing"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -138,18 +138,14 @@ beforeEach(() => {
|
||||
// findMany + null findFirst means the slim slice's parity check
|
||||
// diverges and falls back to live SQL (preserves the pre-v1.4.35
|
||||
// assertions in this file).
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue(
|
||||
{ count: 0 } as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue({} as never);
|
||||
// v1.4.37 W2 — `ensureUserRollupsFresh` reads `measurement.findFirst`;
|
||||
// the per-type coverage probe + the rollup-recompute aggregator ride
|
||||
// `$queryRaw` / `$queryRawUnsafe`. Default to empty so the route
|
||||
@@ -318,12 +314,10 @@ describe("GET /api/analytics", () => {
|
||||
{ type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo },
|
||||
] as never);
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/api/analytics?slice=summaries",
|
||||
const req = new Request("http://localhost/api/analytics?slice=summaries");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
req,
|
||||
);
|
||||
const res = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(req);
|
||||
expect(res.status).toBe(200);
|
||||
// v1.4.34 IW-B — bfcache-friendly directive rides on the slim
|
||||
// slice too.
|
||||
@@ -369,17 +363,17 @@ describe("GET /api/analytics", () => {
|
||||
// both fields rather than 403-ing the whole core-metrics payload.
|
||||
function seedGlucoseRows() {
|
||||
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async (args: { where?: { type?: string } }) => {
|
||||
if (args?.where?.type === "BLOOD_GLUCOSE") {
|
||||
return [
|
||||
{ value: 95, measuredAt, glucoseContext: "FASTING" },
|
||||
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
|
||||
where?: { type?: string };
|
||||
}) => {
|
||||
if (args?.where?.type === "BLOOD_GLUCOSE") {
|
||||
return [
|
||||
{ value: 95, measuredAt, glucoseContext: "FASTING" },
|
||||
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
}
|
||||
|
||||
it("serves glucose clinical + per-context when glucose enabled", async () => {
|
||||
|
||||
@@ -72,7 +72,9 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// instantly while one background recompute refreshes them, instead
|
||||
// of paying the 2-SQL-pass rebuild inline.
|
||||
const slim = await cachedSwr(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof computeSummariesSlice>>
|
||||
>,
|
||||
`${user.id}|summaries`,
|
||||
() => computeSummariesSlice(user.id),
|
||||
annotate,
|
||||
@@ -95,7 +97,9 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// hero score; after a measurement sync the next mount serves the
|
||||
// prior body instantly while one background recompute refreshes it.
|
||||
const cachedBody = await cachedSwr(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildAnalyticsResponse>>
|
||||
>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
annotate,
|
||||
@@ -596,7 +600,6 @@ async function computeSleepStageBreakdown(
|
||||
// used by the weight-weekday + day-key helpers inside the old inline
|
||||
// correlation builder.
|
||||
|
||||
|
||||
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
|
||||
// and the inline `computeUserHealthScore` body relocated to
|
||||
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
|
||||
|
||||
@@ -52,8 +52,7 @@ vi.mock("@/lib/auth/hmac", () => ({
|
||||
// privacy contract is "the typed identifier never serialises into
|
||||
// the row's body", so the mock must not embed the raw input.
|
||||
hashToken: vi.fn(
|
||||
(raw: string) =>
|
||||
"aa".repeat(32) + (raw.length % 8).toString(16),
|
||||
(raw: string) => "aa".repeat(32) + (raw.length % 8).toString(16),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -87,12 +87,14 @@ function mkPatch(body: unknown): Request {
|
||||
* (which writes `modulePreferencesJson`) is reflected by the post-update
|
||||
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
|
||||
*/
|
||||
function primeUser(over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {}) {
|
||||
function primeUser(
|
||||
over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {},
|
||||
) {
|
||||
const row: {
|
||||
gender: string | null;
|
||||
disableCoach: boolean;
|
||||
@@ -102,9 +104,8 @@ function primeUser(over: {
|
||||
disableCoach: over.disableCoach ?? false,
|
||||
modulePreferencesJson: over.modulePreferencesJson ?? null,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation(
|
||||
(() => Promise.resolve({ ...row })) as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
|
||||
Promise.resolve({ ...row })) as never);
|
||||
vi.mocked(prisma.user.update).mockImplementation(((args: {
|
||||
data?: { modulePreferencesJson?: unknown };
|
||||
}) => {
|
||||
@@ -213,7 +214,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
// user previously disabled glucose; now disabling sleep too.
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ sleep: false }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ sleep: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
@@ -225,7 +228,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ glucose: true }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucose: true }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
@@ -299,7 +304,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
// junk in the stored row must not leak into the persisted merge.
|
||||
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ labs: false }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ labs: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
|
||||
@@ -131,9 +131,7 @@ describe("GET /api/auth/me/research-mode", () => {
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +163,7 @@ describe("POST /api/auth/me/research-mode", () => {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -171,7 +171,8 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
|
||||
researchModeAcknowledgedAt:
|
||||
next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,7 +131,10 @@ describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
"user.unit-preference.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ previous: "metric", next: "imperial" }),
|
||||
details: expect.objectContaining({
|
||||
previous: "metric",
|
||||
next: "imperial",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
@@ -53,10 +53,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
|
||||
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
checkPasswordStrength: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -81,9 +83,7 @@ beforeEach(() => {
|
||||
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Empty currentPassword + tooShort newPassword.
|
||||
const res = await POST(
|
||||
postReq({ currentPassword: "", newPassword: "x" }),
|
||||
);
|
||||
const res = await POST(postReq({ currentPassword: "", newPassword: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
@@ -102,9 +102,7 @@ describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Both empty + extra invalid type.
|
||||
const res = await POST(
|
||||
postReq({ currentPassword: "", newPassword: 123 }),
|
||||
);
|
||||
const res = await POST(postReq({ currentPassword: "", newPassword: 123 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
|
||||
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/session", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
checkPasswordStrength: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
@@ -144,7 +144,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "auth.register.invite_rejected" },
|
||||
meta: { reason: consumed.reason, registration_open: registrationEnabled },
|
||||
meta: {
|
||||
reason: consumed.reason,
|
||||
registration_open: registrationEnabled,
|
||||
},
|
||||
});
|
||||
if (!registrationEnabled) {
|
||||
return apiError("Invalid or expired invite", 403);
|
||||
|
||||
@@ -89,9 +89,7 @@ describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// Schema has only two knobs (description, screenshot). Pin contract
|
||||
// on ≥ 2.
|
||||
const res = await POST(
|
||||
postReq({ description: 123, screenshot: 999 }),
|
||||
);
|
||||
const res = await POST(postReq({ description: 123, screenshot: 999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
|
||||
@@ -46,10 +46,7 @@ function reject() {
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
ctx: { params: Promise<{ token: string }> },
|
||||
) => {
|
||||
async (request: NextRequest, ctx: { params: Promise<{ token: string }> }) => {
|
||||
const { token } = await ctx.params;
|
||||
annotate({ action: { name: "share-link.unlock" } });
|
||||
|
||||
@@ -91,14 +88,18 @@ export const POST = apiHandler(
|
||||
|
||||
// Correct passphrase — mint the short-lived, token-scoped unlock cookie.
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(unlockCookieName(gate.tokenHash), mintUnlockValue(gate.tokenHash), {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "strict",
|
||||
// Scope to THIS token's view path only — the cookie unlocks nothing else.
|
||||
path: `/c/${token}`,
|
||||
maxAge: UNLOCK_TTL_SECONDS,
|
||||
});
|
||||
cookieStore.set(
|
||||
unlockCookieName(gate.tokenHash),
|
||||
mintUnlockValue(gate.tokenHash),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "strict",
|
||||
// Scope to THIS token's view path only — the cookie unlocks nothing else.
|
||||
path: `/c/${token}`,
|
||||
maxAge: UNLOCK_TTL_SECONDS,
|
||||
},
|
||||
);
|
||||
|
||||
annotate({ meta: { unlock: "granted" } });
|
||||
return apiSuccess({ unlocked: true });
|
||||
|
||||
@@ -13,11 +13,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getPendingQuestionsForUser,
|
||||
|
||||
@@ -38,10 +38,7 @@ import {
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
|
||||
import {
|
||||
CADENCE_CATALOG,
|
||||
isCadenceId,
|
||||
} from "@/lib/ai/coach/suggest-reminder";
|
||||
import { CADENCE_CATALOG, isCadenceId } from "@/lib/ai/coach/suggest-reminder";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
|
||||
@@ -36,7 +36,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
@@ -440,9 +440,7 @@ describe("append-only invariant", () => {
|
||||
it("GET surfaces multi-issue validation errors (≥ 1)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=junk",
|
||||
),
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=junk"),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
@@ -467,10 +465,9 @@ describe("append-only invariant", () => {
|
||||
it("DELETE surfaces multi-issue validation errors (≥ 1)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=junk",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=junk", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
|
||||
@@ -16,11 +16,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -76,7 +72,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// Map each enum value to either the serialised row or `null` so the
|
||||
// client always sees the full keyspace and never has to guess
|
||||
// whether a missing key means "not granted" or "schema gap".
|
||||
const result: Record<ConsentKind, ReturnType<typeof serialiseReceipt> | null> = {
|
||||
const result: Record<
|
||||
ConsentKind,
|
||||
ReturnType<typeof serialiseReceipt> | null
|
||||
> = {
|
||||
ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null,
|
||||
ai_insights_only: byKind.ai_insights_only
|
||||
? serialiseReceipt(byKind.ai_insights_only)
|
||||
|
||||
@@ -53,7 +53,12 @@ import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const, gender: "FEMALE" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "t",
|
||||
role: "USER" as const,
|
||||
gender: "FEMALE",
|
||||
},
|
||||
};
|
||||
|
||||
function jsonReq(url: string, method: string, body: unknown) {
|
||||
@@ -92,7 +97,10 @@ describe("GET /api/cycle/symptoms/custom", () => {
|
||||
describe("POST /api/cycle/symptoms/custom", () => {
|
||||
it("creates a custom symptom, stores the label encrypted, returns it", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(2);
|
||||
db.cycleSymptom.create.mockResolvedValue({ key: "custom:abc", icon: "Brain" });
|
||||
db.cycleSymptom.create.mockResolvedValue({
|
||||
key: "custom:abc",
|
||||
icon: "Brain",
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "Dizziness",
|
||||
@@ -122,7 +130,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
|
||||
it("rejects over the per-user cap with 422", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(50);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "X",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.cycleSymptom.create).not.toHaveBeenCalled();
|
||||
@@ -134,7 +144,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
|
||||
response: apiError("Cycle tracking is not enabled", 403),
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "X",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
|
||||
@@ -170,9 +182,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: false });
|
||||
db.cycleSymptom.count.mockResolvedValue(50);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
|
||||
isActive: true,
|
||||
}),
|
||||
jsonReq(
|
||||
"http://localhost/api/cycle/symptoms/custom/custom:abc",
|
||||
"PATCH",
|
||||
{
|
||||
isActive: true,
|
||||
},
|
||||
),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
@@ -188,9 +204,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
labelEncrypted: "enc:Renamed",
|
||||
});
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
|
||||
label: "Renamed",
|
||||
}),
|
||||
jsonReq(
|
||||
"http://localhost/api/cycle/symptoms/custom/custom:abc",
|
||||
"PATCH",
|
||||
{
|
||||
label: "Renamed",
|
||||
},
|
||||
),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -224,6 +244,8 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(purge.status).toBe(200);
|
||||
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
|
||||
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({
|
||||
where: { id: "id1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,9 @@ export const GET = apiHandler(async () => {
|
||||
// Only surface timings on the cache-miss path (the hit path skips the
|
||||
// whole builder and leaves `timings` empty).
|
||||
if (Object.keys(timings).length > 0) {
|
||||
annotate({ meta: { ...timings, snapshot_extras_present: body.extras !== null } });
|
||||
annotate({
|
||||
meta: { ...timings, snapshot_extras_present: body.extras !== null },
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiSuccess(body);
|
||||
|
||||
@@ -325,7 +325,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
};
|
||||
};
|
||||
const bp = body.data.metrics.find((m) => m.id === "bp");
|
||||
expect(bp, "bp tile must be emitted for stale-but-valid history").toBeDefined();
|
||||
expect(
|
||||
bp,
|
||||
"bp tile must be emitted for stale-but-valid history",
|
||||
).toBeDefined();
|
||||
expect(bp?.latestValue).toBe(132);
|
||||
expect(bp?.secondaryValue).toBe(84);
|
||||
expect(bp?.allTimeCount).toBe(28);
|
||||
@@ -421,13 +424,55 @@ describe("GET /api/dashboard/summary", () => {
|
||||
{ type: "PULSE", value: 71, measured_at: days(30) },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", bucket_start: days(36), mean: 68, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(35), mean: 70, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(34), mean: 69, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(33), mean: 71, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(32), mean: 73, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(31), mean: 72, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(30), mean: 71, count: 3, sum_value: null },
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(36),
|
||||
mean: 68,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(35),
|
||||
mean: 70,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(34),
|
||||
mean: 69,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(33),
|
||||
mean: 71,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(32),
|
||||
mean: 73,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(31),
|
||||
mean: 72,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(30),
|
||||
mean: 71,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
@@ -498,10 +543,26 @@ describe("GET /api/dashboard/summary", () => {
|
||||
] as never);
|
||||
// Raw per-stage rows for the night (the sleep findMany read).
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
|
||||
{ value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
|
||||
{ value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
|
||||
{ value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
|
||||
{
|
||||
value: 480,
|
||||
measuredAt: new Date("2026-06-03T23:00:00.000Z"),
|
||||
sleepStage: "IN_BED",
|
||||
},
|
||||
{
|
||||
value: 240,
|
||||
measuredAt: new Date("2026-06-04T00:00:00.000Z"),
|
||||
sleepStage: "CORE",
|
||||
},
|
||||
{
|
||||
value: 90,
|
||||
measuredAt: new Date("2026-06-04T02:00:00.000Z"),
|
||||
sleepStage: "DEEP",
|
||||
},
|
||||
{
|
||||
value: 80,
|
||||
measuredAt: new Date("2026-06-04T04:00:00.000Z"),
|
||||
sleepStage: "REM",
|
||||
},
|
||||
{ value: 20, measuredAt: wake, sleepStage: "AWAKE" },
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
|
||||
@@ -334,9 +334,9 @@ export const GET = apiHandler(async () => {
|
||||
// existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
// `invalidateUserMedications` hooks.
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildDashboardSummary>
|
||||
>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildDashboardSummary>>
|
||||
>,
|
||||
`${user.id}|dashboard-summary`,
|
||||
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
|
||||
annotate,
|
||||
@@ -350,9 +350,11 @@ interface SummaryBuilderContext {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
user: { displayName: string | null; username: string; locale: string | null },
|
||||
): SummaryBuilderContext {
|
||||
function buildContext(user: {
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
locale: string | null;
|
||||
}): SummaryBuilderContext {
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
// v1.5.x — accept every shipped locale (de / en / es / fr / it / pl)
|
||||
// so the greeting + streak label resolve against the user's
|
||||
@@ -395,7 +397,10 @@ async function buildDashboardSummary(
|
||||
// perf-verify can attribute a regression to a specific sub-query
|
||||
// without re-instrumenting the route.
|
||||
const timings: Record<string, number> = {};
|
||||
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
|
||||
const time = async <T>(
|
||||
label: string,
|
||||
builder: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const t0 = Date.now();
|
||||
const result = await builder();
|
||||
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
|
||||
@@ -424,8 +429,10 @@ async function buildDashboardSummary(
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
] = await Promise.all([
|
||||
time("latestEver", () =>
|
||||
prisma.$queryRaw<LatestEverRow[]>`
|
||||
time(
|
||||
"latestEver",
|
||||
() =>
|
||||
prisma.$queryRaw<LatestEverRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type" AS type,
|
||||
m."value"::double precision AS value,
|
||||
@@ -436,8 +443,10 @@ async function buildDashboardSummary(
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
),
|
||||
time("sparkline", () =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
time(
|
||||
"sparkline",
|
||||
() =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
SELECT type, bucket_start, mean, count, sum_value
|
||||
FROM (
|
||||
SELECT
|
||||
@@ -497,8 +506,10 @@ async function buildDashboardSummary(
|
||||
// conversion is folded server-side so the day boundary matches
|
||||
// `userDayKey`'s output (it formats the timestamp in the user's
|
||||
// tz before slicing YYYY-MM-DD).
|
||||
time("streakDays", () =>
|
||||
prisma.$queryRaw<ActivityDayRow[]>`
|
||||
time(
|
||||
"streakDays",
|
||||
() =>
|
||||
prisma.$queryRaw<ActivityDayRow[]>`
|
||||
SELECT DISTINCT
|
||||
to_char(
|
||||
(m."measured_at" AT TIME ZONE ${userTz}),
|
||||
|
||||
@@ -37,8 +37,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
@@ -188,11 +187,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
@@ -206,7 +207,7 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
|
||||
256,
|
||||
);
|
||||
expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
|
||||
expect(meta.received_shape_excerpt as string).toContain('"version":2');
|
||||
|
||||
// zod_issues is the same sanitised array surfaced under details.issues.
|
||||
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
|
||||
@@ -230,11 +231,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
const res = await callPut(makeReq({ version: 99, widgets }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
expect((meta.received_shape_excerpt as string).length).toBe(256);
|
||||
});
|
||||
@@ -268,11 +271,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
@@ -337,7 +342,8 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
|
||||
|
||||
// The persisted blob never carries the unknown id.
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const updateArg = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
@@ -372,11 +378,13 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
|
||||
// annotation fires regardless, before the Zod parse.
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate, "unknown-id-dropped annotate call").toBeTruthy();
|
||||
const meta = (dropAnnotate![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
expect(meta.dropped_count).toBe(200);
|
||||
@@ -542,11 +550,13 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// No id was dropped — the unknown-id annotation must NOT fire.
|
||||
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate).toBeUndefined();
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
@@ -557,7 +567,9 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
(w) => w.id,
|
||||
);
|
||||
expect(persistedIds.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
|
||||
expect(persistedIds.sort()).toEqual(
|
||||
[...DASHBOARD_WIDGET_CATALOGUE_IDS].sort(),
|
||||
);
|
||||
// The response body echoes the full persisted layout.
|
||||
const body = (await res.json()) as {
|
||||
data: { widgets: Array<{ id: string }> };
|
||||
|
||||
@@ -271,7 +271,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
|
||||
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
|
||||
if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) {
|
||||
if (
|
||||
mergedChartOverlayPrefs === undefined ||
|
||||
mergedHeroVisible === undefined
|
||||
) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
|
||||
@@ -104,7 +104,9 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null } as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
@@ -140,9 +142,7 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
|
||||
it("/api/gamification/achievements scopes the measurement read to deletedAt: null", async () => {
|
||||
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
|
||||
const { GET } = await import(
|
||||
"../../gamification/achievements/route"
|
||||
);
|
||||
const { GET } = await import("../../gamification/achievements/route");
|
||||
await GET(mkReq("http://localhost/api/gamification/achievements"));
|
||||
const calls = vi.mocked(prisma.measurement.findMany).mock.calls;
|
||||
// The achievement aggregator reads recent measurements via
|
||||
|
||||
@@ -104,9 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const locale: Locale =
|
||||
selection.locale ??
|
||||
cookieLocale ??
|
||||
parseLocaleFromAcceptLanguage(
|
||||
request.headers.get("accept-language") ?? "",
|
||||
);
|
||||
parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
|
||||
|
||||
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
|
||||
// German-region locale. The WHO ATC coding is unaffected either way.
|
||||
@@ -207,12 +205,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
);
|
||||
const bundle = buildFhirDocumentBundle(data, { insuranceNumber }, undefined, {
|
||||
germanAtc,
|
||||
});
|
||||
const readme = t("doctorReport.packageReadme");
|
||||
const zipped = zipSync(
|
||||
{
|
||||
|
||||
@@ -46,8 +46,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? "raw"
|
||||
: "night";
|
||||
|
||||
const [measurements, userTz, sourcePriorityJson, profile] =
|
||||
await Promise.all([
|
||||
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
|
||||
[
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
@@ -60,7 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
}),
|
||||
]);
|
||||
],
|
||||
);
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
|
||||
const csv = toCSV(
|
||||
|
||||
@@ -17,8 +17,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/fhir/rest")>();
|
||||
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
|
||||
return { ...actual, loadFhirContext: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/fhir/resources", () => ({
|
||||
|
||||
@@ -61,8 +61,9 @@ describe("/api/fitbit/credentials", () => {
|
||||
(
|
||||
prisma.fitbitConnection.delete as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({});
|
||||
const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)())
|
||||
.data as { deleted: boolean };
|
||||
const res = (
|
||||
await (DELETE as unknown as () => Promise<{ data: unknown }>)()
|
||||
).data as { deleted: boolean };
|
||||
expect(res.deleted).toBe(true);
|
||||
expect(userUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "u1" },
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("GET /api/gamification/achievements — achievements module gate", () =
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError("Module \"achievements\" is not enabled", 403, {
|
||||
response: apiError('Module "achievements" is not enabled', 403, {
|
||||
errorCode: "module.disabled",
|
||||
module: "achievements",
|
||||
}),
|
||||
@@ -216,8 +216,7 @@ describe("GET /api/gamification/achievements — per-module badge skipping", ()
|
||||
// A mood badge would unlock at >=1 entry; with mood OFF the createMany
|
||||
// must carry no mood achievement row.
|
||||
await badgesFor({ mood: false });
|
||||
const createCalls = vi.mocked(prisma.userAchievement.createMany).mock
|
||||
.calls;
|
||||
const createCalls = vi.mocked(prisma.userAchievement.createMany).mock.calls;
|
||||
for (const [arg] of createCalls) {
|
||||
const rows = (arg as { data: Array<{ achievementId: string }> }).data;
|
||||
expect(rows.some((r) => r.achievementId.includes("mood"))).toBe(false);
|
||||
|
||||
@@ -44,7 +44,11 @@ export const PATCH = apiHandler(
|
||||
onsetAt: true,
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Episode not found", 404);
|
||||
}
|
||||
if (existing.lifecycle === "CHRONIC_ONGOING") {
|
||||
|
||||
@@ -75,10 +75,9 @@ function liveRow(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
function req(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/illness/episodes/ep-1/restore",
|
||||
{ method: "POST" },
|
||||
);
|
||||
return new NextRequest("http://localhost/api/illness/episodes/ep-1/restore", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -71,7 +71,11 @@ export const PATCH = apiHandler(
|
||||
resolvedAt: true,
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Episode not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = illnessInsightsQuerySchema.safeParse({
|
||||
windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined,
|
||||
windowDays:
|
||||
new URL(request.url).searchParams.get("windowDays") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
@@ -79,7 +80,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
limit(async (): Promise<[string, number | null]> => {
|
||||
const derived = await computeEpisodeCorrelation(
|
||||
user.id,
|
||||
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
|
||||
{
|
||||
id: r.id,
|
||||
onsetAt: r.onsetAt,
|
||||
resolvedAt: r.resolvedAt,
|
||||
lifecycle: r.lifecycle,
|
||||
},
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -63,8 +63,13 @@ function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
exportedAt: null,
|
||||
startedAt: NOW,
|
||||
completedAt: null,
|
||||
progress: { currentPhase: "parsing", recordsRead: 100, rowsUpserted: 0,
|
||||
percent: null, elapsedMs: 1500 },
|
||||
progress: {
|
||||
currentPhase: "parsing",
|
||||
recordsRead: 100,
|
||||
rowsUpserted: 0,
|
||||
percent: null,
|
||||
elapsedMs: 1500,
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
@@ -78,10 +83,15 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
|
||||
it("returns the row envelope to the owning user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
|
||||
mocks.importJobFindUnique.mockResolvedValue(makeRow());
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export/ij-1/status",
|
||||
);
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { status: string }; error: null };
|
||||
const body = (await res.json()) as {
|
||||
data: { status: string };
|
||||
error: null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.status).toBe("parsing");
|
||||
});
|
||||
@@ -89,7 +99,9 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
|
||||
it("returns 404 to a non-owner non-admin user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(OTHER_SESSION as never);
|
||||
mocks.importJobFindUnique.mockResolvedValue(makeRow());
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export/ij-1/status",
|
||||
);
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -99,7 +111,9 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
|
||||
mocks.importJobFindUnique.mockResolvedValue(
|
||||
makeRow({ triggeredByAdminId: "admin-1" }),
|
||||
);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export/ij-1/status",
|
||||
);
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
@@ -107,14 +121,18 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
|
||||
it("returns 404 when the job row is missing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
|
||||
mocks.importJobFindUnique.mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export/ij-1/status",
|
||||
);
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export/ij-1/status",
|
||||
);
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -74,11 +74,14 @@ beforeEach(() => {
|
||||
describe("POST /api/import/apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
@@ -89,11 +92,14 @@ describe("POST /api/import/apple-health-export", () => {
|
||||
"content-type": "multipart/form-data; boundary=---x",
|
||||
"content-length": String(2 * 1024 * 1024 * 1024),
|
||||
});
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
@@ -101,11 +107,14 @@ describe("POST /api/import/apple-health-export", () => {
|
||||
it("returns 429 when the rate-limit window is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/import/apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
@@ -174,9 +174,7 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n"),
|
||||
),
|
||||
csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(0);
|
||||
|
||||
@@ -125,7 +125,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
@@ -136,7 +137,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
externalId: m.externalId,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
select: { createdAt: true, updatedAt: true },
|
||||
});
|
||||
@@ -159,7 +161,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: "IMPORT",
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
@@ -232,7 +235,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const outcome = writeOutcome.get(r.line);
|
||||
if (outcome === "duplicate") {
|
||||
return { line: r.line, status: "skipped" as const, reason: "duplicate" };
|
||||
return {
|
||||
line: r.line,
|
||||
status: "skipped" as const,
|
||||
reason: "duplicate",
|
||||
};
|
||||
}
|
||||
if (outcome === "updated") {
|
||||
return { line: r.line, status: "updated" as const };
|
||||
|
||||
@@ -99,9 +99,7 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
// `medicationName=""` (min-1 violation) + `takenAt="not-iso"`
|
||||
// forces two issues. idempotencyKey is omitted so we also catch
|
||||
// the required-field check.
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "", takenAt: "not-iso" }),
|
||||
);
|
||||
const res = await POST(postReq({ medicationName: "", takenAt: "not-iso" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
@@ -311,9 +309,8 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
// ── v1.16.10 — inventory consumption seam ───────────────────────────
|
||||
|
||||
it("consumes inventory exactly once on the landed row", async () => {
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
wireHappyPath();
|
||||
const res = await POST(
|
||||
postReq({
|
||||
@@ -334,9 +331,8 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
});
|
||||
|
||||
it("an idempotency replay returns the original row without consuming", async () => {
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
wireHappyPath();
|
||||
// The replay probe finds the original event.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
|
||||
@@ -57,14 +57,17 @@ import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/insights/blood-pressure-status",
|
||||
);
|
||||
return new NextRequest("http://localhost/api/insights/blood-pressure-status");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -70,7 +70,10 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
listConversations: vi.fn(async () => ({ conversations: [], nextCursor: null })),
|
||||
listConversations: vi.fn(async () => ({
|
||||
conversations: [],
|
||||
nextCursor: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
@@ -82,12 +85,16 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
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/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/ai/coach/keyvalues", () => ({
|
||||
parseKeyValuesSentinel: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
|
||||
|
||||
|
||||
@@ -571,7 +571,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
prisma,
|
||||
userId,
|
||||
cadence: suggestParse.cadence,
|
||||
prefs: coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
prefs:
|
||||
coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
});
|
||||
if (decision.surface) {
|
||||
const cadence = suggestParse.cadence;
|
||||
@@ -613,9 +614,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
}
|
||||
const enrichedProvenance: typeof snapshot.provenance = {
|
||||
...snapshot.provenance,
|
||||
...(sentinel.keyValues.length > 0
|
||||
? { keyValues: sentinel.keyValues }
|
||||
: {}),
|
||||
...(sentinel.keyValues.length > 0 ? { keyValues: sentinel.keyValues } : {}),
|
||||
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
|
||||
};
|
||||
if (sentinel.malformed) {
|
||||
|
||||
@@ -58,8 +58,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
@@ -167,10 +166,7 @@ describe("GET /api/insights/coach/facts", () => {
|
||||
await callGet();
|
||||
const orderBy = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
|
||||
?.orderBy;
|
||||
expect(orderBy).toEqual([
|
||||
{ confidence: "desc" },
|
||||
{ createdAt: "desc" },
|
||||
]);
|
||||
expect(orderBy).toEqual([{ confidence: "desc" }, { createdAt: "desc" }]);
|
||||
});
|
||||
|
||||
it("skips an undecryptable row rather than 500ing the whole list", async () => {
|
||||
|
||||
@@ -35,12 +35,14 @@ vi.mock("@/lib/db", () => ({
|
||||
// real recompute against the mocked Prisma. The route fires the
|
||||
// warm-up as fire-and-forget; the return value is irrelevant.
|
||||
vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
|
||||
"@/lib/rollups/mood-rollups",
|
||||
);
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/mood-rollups")
|
||||
>("@/lib/rollups/mood-rollups");
|
||||
return {
|
||||
...actual,
|
||||
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
|
||||
ensureUserMoodRollupsFresh: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ recomputed: false }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -118,9 +120,9 @@ beforeEach(() => {
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// Default to assistant-on so the gate doesn't 403 every test.
|
||||
(
|
||||
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(null);
|
||||
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
@@ -146,15 +148,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
|
||||
it("emits every legacy envelope key for an empty user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
@@ -202,31 +204,31 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
|
||||
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
count: 1,
|
||||
latest: 81.0,
|
||||
min: 81.0,
|
||||
max: 81.0,
|
||||
mean: 81.0,
|
||||
avg7: 81.0,
|
||||
avg30: 81.0,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
count: 1,
|
||||
latest: 81.0,
|
||||
min: 81.0,
|
||||
max: 81.0,
|
||||
mean: 81.0,
|
||||
avg7: 81.0,
|
||||
avg30: 81.0,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 1,
|
||||
},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 1,
|
||||
});
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
@@ -240,49 +242,49 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
it("pairs sys + dia raw rows with 5-min tolerance for bpPctInTarget", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const at = new Date("2026-05-10T08:00:00Z");
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
count: 1,
|
||||
latest: 125,
|
||||
min: 125,
|
||||
max: 125,
|
||||
mean: 125,
|
||||
avg7: 125,
|
||||
avg30: 125,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
count: 1,
|
||||
latest: 125,
|
||||
min: 125,
|
||||
max: 125,
|
||||
mean: 125,
|
||||
avg7: 125,
|
||||
avg30: 125,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
BLOOD_PRESSURE_DIA: {
|
||||
count: 1,
|
||||
latest: 75,
|
||||
min: 75,
|
||||
max: 75,
|
||||
mean: 75,
|
||||
avg7: 75,
|
||||
avg30: 75,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
},
|
||||
BLOOD_PRESSURE_DIA: {
|
||||
count: 1,
|
||||
latest: 75,
|
||||
min: 75,
|
||||
max: 75,
|
||||
mean: 75,
|
||||
avg7: 75,
|
||||
avg30: 75,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
bpRawRows: {
|
||||
sys: [{ measuredAt: at, value: 125 }],
|
||||
dia: [{ measuredAt: at, value: 75 }],
|
||||
},
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: at,
|
||||
totalMeasurements: 2,
|
||||
},
|
||||
bpRawRows: {
|
||||
sys: [{ measuredAt: at, value: 125 }],
|
||||
dia: [{ measuredAt: at, value: 75 }],
|
||||
},
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: at,
|
||||
totalMeasurements: 2,
|
||||
});
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
@@ -305,15 +307,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
// mean DataPoints, matching the rollup-tier convention shipped by
|
||||
// `/api/mood/analytics` post-v1.4.39.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
},
|
||||
);
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
@@ -364,15 +366,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
// supposed to fire fire-and-forget so the next request lands on
|
||||
// the rollup tier.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
},
|
||||
);
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
@@ -411,18 +413,21 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
];
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {
|
||||
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
|
||||
BLOOD_PRESSURE_SYS: days.map((d, i) => ({ day: d, value: 120 + i * 2 })),
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {
|
||||
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
|
||||
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
|
||||
day: d,
|
||||
value: 120 + i * 2,
|
||||
})),
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
});
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
@@ -454,9 +459,9 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
|
||||
|
||||
it("marks a cold (inline-computed) read revalidating: false", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(EMPTY_AGGREGATE);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
@@ -466,9 +471,9 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
|
||||
|
||||
it("marks a stale-served read revalidating: true and a converged read false again", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(EMPTY_AGGREGATE);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
|
||||
// Prime the cache entry (cold compute).
|
||||
await callGet(makeReq());
|
||||
|
||||
@@ -74,9 +74,9 @@ export const GET = apiHandler(async () => {
|
||||
// the response, never inside the cached body, so a warmed entry
|
||||
// doesn't freeze the flag.
|
||||
const { value: body, outcome } = await cachedSwrWithMeta(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildComprehensiveResponse>
|
||||
>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildComprehensiveResponse>>
|
||||
>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
@@ -241,10 +241,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// small semantic shift to bound the raw-row footprint).
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(
|
||||
weightDaily,
|
||||
sysDaily,
|
||||
);
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
|
||||
const weightBpCorrelation = pearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
|
||||
@@ -5,7 +5,9 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) },
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }),
|
||||
},
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
|
||||
@@ -117,7 +117,10 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const measurementsByType = new Map<string, Array<{ value: number; at: Date }>>();
|
||||
const measurementsByType = new Map<
|
||||
string,
|
||||
Array<{ value: number; at: Date }>
|
||||
>();
|
||||
for (const m of measurements) {
|
||||
const list = measurementsByType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
@@ -134,12 +137,16 @@ export const GET = apiHandler(async () => {
|
||||
const series: NamedSeries[] = [];
|
||||
for (const key of DISCOVERY_BEHAVIOURS) {
|
||||
const points =
|
||||
key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz);
|
||||
key === "MOOD"
|
||||
? moodDaily
|
||||
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
|
||||
series.push({ key, role: "behaviour", points });
|
||||
}
|
||||
for (const key of DISCOVERY_OUTCOMES) {
|
||||
const points =
|
||||
key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz);
|
||||
key === "MOOD"
|
||||
? moodDaily
|
||||
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
|
||||
series.push({ key, role: "outcome", points });
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,28 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
...actual,
|
||||
computeDerivedMetric: vi.fn(async () => ({
|
||||
status: "ok",
|
||||
value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 },
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
value: {
|
||||
type: "RESTING_HEART_RATE",
|
||||
center: 55,
|
||||
low: 48,
|
||||
high: 62,
|
||||
spread: 7,
|
||||
sampleDays: 30,
|
||||
k: 3,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 1,
|
||||
historyDays: 30,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
provenance: {
|
||||
inputs: ["RESTING_HEART_RATE"],
|
||||
source: "DAY",
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -74,7 +92,12 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -152,14 +175,29 @@ describe("GET /api/insights/derived", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(computeDerivedMetric).mockResolvedValueOnce({
|
||||
status: "insufficient",
|
||||
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] },
|
||||
provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: ["RESTING_HEART_RATE"],
|
||||
},
|
||||
provenance: {
|
||||
inputs: ["RESTING_HEART_RATE"],
|
||||
source: "none",
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
},
|
||||
reason: "no_readings_in_window",
|
||||
} as never);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { status: string; value: unknown; confidence: unknown; reason: string } | null;
|
||||
data: {
|
||||
status: string;
|
||||
value: unknown;
|
||||
confidence: unknown;
|
||||
reason: string;
|
||||
} | null;
|
||||
};
|
||||
expect(body.data?.status).toBe("insufficient");
|
||||
expect(body.data?.value).toBeNull();
|
||||
|
||||
@@ -103,7 +103,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
||||
const moods = await prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
select: { moodLoggedAt: true, tags: true },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
|
||||
@@ -36,8 +36,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
@@ -240,7 +239,8 @@ describe("PUT /api/insights/layout — happy path", () => {
|
||||
expect(returnedIds).not.toContain("medikamente");
|
||||
|
||||
// The persisted blob is canonical too.
|
||||
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
|
||||
const updateCall = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { insightsLayoutJson: { tiles: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateCall.data.insightsLayoutJson.tiles.map(
|
||||
@@ -291,9 +291,9 @@ describe("PUT /api/insights/layout — accepts version 1 (iOS contract)", () =>
|
||||
data: { insightsLayoutJson: { version: number; sections: unknown[] } };
|
||||
};
|
||||
expect(updateCall.data.insightsLayoutJson.version).toBe(2);
|
||||
expect(
|
||||
Array.isArray(updateCall.data.insightsLayoutJson.sections),
|
||||
).toBe(true);
|
||||
expect(Array.isArray(updateCall.data.insightsLayoutJson.sections)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -396,9 +396,9 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => {
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.startsWith("tiles")),
|
||||
).toBe(true);
|
||||
expect(body.details.issues.some((i) => i.path.startsWith("tiles"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,9 +436,9 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.startsWith("tiles")),
|
||||
).toBe(true);
|
||||
expect(body.details.issues.some((i) => i.path.startsWith("tiles"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
|
||||
@@ -488,11 +488,13 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
@@ -524,11 +526,13 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
@@ -578,7 +582,9 @@ describe("PUT /api/insights/layout — v2 sections", () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { sections: Array<{ id: string; visible: boolean; order: number }> };
|
||||
data: {
|
||||
sections: Array<{ id: string; visible: boolean; order: number }>;
|
||||
};
|
||||
};
|
||||
// Saved sections surface in saved order, dense 0-based.
|
||||
expect(body.data.sections[0]).toEqual({
|
||||
@@ -651,9 +657,9 @@ describe("PUT /api/insights/layout — v2 sections", () => {
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.startsWith("sections")),
|
||||
).toBe(true);
|
||||
expect(body.details.issues.some((i) => i.path.startsWith("sections"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,12 @@ import { generateMetricStatus } from "@/lib/insights/metric-status";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
@@ -58,9 +58,7 @@ const SESSION_OK = {
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/mood-status"),
|
||||
);
|
||||
return new NextRequest(new URL("http://localhost/api/insights/mood-status"));
|
||||
}
|
||||
|
||||
/** Persisted module-preferences blob for the gated user row. */
|
||||
@@ -81,9 +79,7 @@ beforeEach(() => {
|
||||
describe("GET /api/insights/mood-status — module gate", () => {
|
||||
it("200s when the mood module is enabled (default-on, no blob)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
userRow(null) as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { text: string } | null };
|
||||
|
||||
@@ -20,7 +20,10 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
|
||||
import { PERIOD_DAYS, type NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
|
||||
import {
|
||||
PERIOD_DAYS,
|
||||
type NarrativePeriod,
|
||||
} from "@/lib/insights/narrative/period-narrative";
|
||||
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -57,15 +57,19 @@ import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/pregenerate"),
|
||||
{ method: "POST" },
|
||||
);
|
||||
return new NextRequest(new URL("http://localhost/api/insights/pregenerate"), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -51,12 +51,19 @@ import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(new URL("http://localhost/api/insights/rhythm-events"));
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/rhythm-events"),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -39,7 +39,9 @@ export const dynamic = "force-dynamic";
|
||||
// these a year; the cap is a defensive bound, not an expected ceiling.
|
||||
const MAX_EVENTS = 200;
|
||||
|
||||
const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[];
|
||||
const EVENT_TYPE_LIST = Array.from(
|
||||
EVENT_MEASUREMENT_TYPES,
|
||||
) as MeasurementType[];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -69,12 +69,14 @@ vi.mock("next/headers", () => ({
|
||||
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget
|
||||
// on the route so its return value is irrelevant for parity.
|
||||
vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
|
||||
"@/lib/rollups/mood-rollups",
|
||||
);
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/mood-rollups")
|
||||
>("@/lib/rollups/mood-rollups");
|
||||
return {
|
||||
...actual,
|
||||
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
|
||||
ensureUserMoodRollupsFresh: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ recomputed: false }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -160,11 +162,61 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() },
|
||||
{
|
||||
userId: SESSION_USER.id,
|
||||
granularity: "DAY",
|
||||
bucketStart: day(0),
|
||||
count: 1,
|
||||
mean: 4,
|
||||
minScore: 4,
|
||||
maxScore: 4,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: SESSION_USER.id,
|
||||
granularity: "DAY",
|
||||
bucketStart: day(1),
|
||||
count: 1,
|
||||
mean: 4,
|
||||
minScore: 4,
|
||||
maxScore: 4,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: SESSION_USER.id,
|
||||
granularity: "DAY",
|
||||
bucketStart: day(2),
|
||||
count: 1,
|
||||
mean: 5,
|
||||
minScore: 5,
|
||||
maxScore: 5,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: SESSION_USER.id,
|
||||
granularity: "DAY",
|
||||
bucketStart: day(3),
|
||||
count: 1,
|
||||
mean: 4,
|
||||
minScore: 4,
|
||||
maxScore: 4,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: SESSION_USER.id,
|
||||
granularity: "DAY",
|
||||
bucketStart: day(4),
|
||||
count: 1,
|
||||
mean: 4.5,
|
||||
minScore: 4,
|
||||
maxScore: 5,
|
||||
sd: 0.5,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
// Latest score read — one bounded row.
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -85,9 +85,7 @@ describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
|
||||
|
||||
const entries = await fetchEntries();
|
||||
const keys = entries.map((e) => e.integration);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining(["polar", "oura"]),
|
||||
);
|
||||
expect(keys).toEqual(expect.arrayContaining(["polar", "oura"]));
|
||||
|
||||
const polar = entries.find((e) => e.integration === "polar")!;
|
||||
expect(polar.connected).toBe(true);
|
||||
|
||||
@@ -34,11 +34,7 @@ export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.withings.resume" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`withings-resume:${user.id}`,
|
||||
5,
|
||||
60_000,
|
||||
);
|
||||
const rl = await checkRateLimit(`withings-resume:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many resume requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
|
||||
@@ -117,9 +117,7 @@ describe("POST /api/internal/web-vitals", () => {
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
postRequest({ name: "CLS", value: 0.1, id: "x" }),
|
||||
);
|
||||
const res = await POST(postRequest({ name: "CLS", value: 0.1, id: "x" }));
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
|
||||
@@ -61,9 +61,12 @@ const ROW = {
|
||||
};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurement-reminders/r1/complete", {
|
||||
method: "POST",
|
||||
});
|
||||
return new NextRequest(
|
||||
"http://localhost/api/measurement-reminders/r1/complete",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const params = { params: Promise.resolve({ id: "r1" }) };
|
||||
@@ -102,7 +105,10 @@ describe("POST /api/measurement-reminders/[id]/complete", () => {
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
// Forward-only guard inside the primitive returns satisfied=false.
|
||||
satisfyReminderMock.mockResolvedValue({ satisfied: false, nextDueAt: null });
|
||||
satisfyReminderMock.mockResolvedValue({
|
||||
satisfied: false,
|
||||
nextDueAt: null,
|
||||
});
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
|
||||
@@ -42,7 +42,10 @@ export const GET = apiHandler(async () => {
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
|
||||
// to the end so the actionable items float to the top.
|
||||
orderBy: [{ nextDueAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
|
||||
orderBy: [
|
||||
{ nextDueAt: { sort: "asc", nulls: "last" } },
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -89,8 +92,7 @@ async function postReminder(request: NextRequest): Promise<Response> {
|
||||
|
||||
const timezone = await resolveTimezone(user.id);
|
||||
const now = new Date();
|
||||
const anchorDate =
|
||||
data.anchorDate != null ? new Date(data.anchorDate) : null;
|
||||
const anchorDate = data.anchorDate != null ? new Date(data.anchorDate) : null;
|
||||
|
||||
// Server-authoritative next-due. No satisfy yet → anchors on
|
||||
// anchorDate ?? createdAt (createdAt ≈ now for a fresh row).
|
||||
|
||||
@@ -107,10 +107,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
});
|
||||
|
||||
it("writes a measurements.update.validation-failed audit row", async () => {
|
||||
const res = await PUT(
|
||||
putReq({ value: -1, measuredAt: "junk" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
@@ -125,10 +122,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await PUT(
|
||||
putReq({ value: -1, measuredAt: "junk" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -217,9 +217,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"),
|
||||
);
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"));
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
@@ -255,9 +253,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
|
||||
// hour of `today` on fall-back.
|
||||
it("returns a 23-hour window on the Berlin spring-forward day (2025-03-30)", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2025-03-30"),
|
||||
);
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2025-03-30"));
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
|
||||
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
|
||||
@@ -271,9 +267,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
|
||||
|
||||
it("returns a 25-hour window on the Berlin fall-back day (2025-10-26)", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2025-10-26"),
|
||||
);
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2025-10-26"));
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
|
||||
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
|
||||
@@ -335,17 +329,13 @@ describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
|
||||
// 2, so the drill-down would point at the wrong day. The validator
|
||||
// refine must reject it at parse time.
|
||||
it("rejects impossible calendar dates on dayKey (2026-02-30)", async () => {
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2026-02-30"),
|
||||
);
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-02-30"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects impossible month on dayKey (2026-13-01)", async () => {
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&dayKey=2026-13-01"),
|
||||
);
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-13-01"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -72,7 +72,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "x",
|
||||
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
|
||||
meta: {
|
||||
target: ["userId", "type", "measuredAt", "source", "sleepStage"],
|
||||
},
|
||||
},
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
|
||||
@@ -83,7 +85,10 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as { error: string; meta?: { errorCode?: string } };
|
||||
const json = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(json.error).toMatch(/already exists|duplicate/i);
|
||||
expect(json.meta?.errorCode).toBe("measurement.duplicate_timestamp");
|
||||
});
|
||||
@@ -108,10 +113,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(otherError);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-3", { value: 76 }),
|
||||
{ params: Promise.resolve({ id: "m-3" }) },
|
||||
);
|
||||
const res = await PUT(putRequest("m-3", { value: 76 }), {
|
||||
params: Promise.resolve({ id: "m-3" }),
|
||||
});
|
||||
|
||||
// apiHandler converts uncaught errors to 500.
|
||||
expect(res.status).toBe(500);
|
||||
@@ -143,7 +147,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "x",
|
||||
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
|
||||
meta: {
|
||||
target: ["userId", "type", "measuredAt", "source", "sleepStage"],
|
||||
},
|
||||
},
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
|
||||
@@ -186,10 +192,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
|
||||
unit: "kg",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-4", { value: 76 }),
|
||||
{ params: Promise.resolve({ id: "m-4" }) },
|
||||
);
|
||||
const res = await PUT(putRequest("m-4", { value: 76 }), {
|
||||
params: Promise.resolve({ id: "m-4" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { data: { id: string; value: number } };
|
||||
|
||||
@@ -324,9 +324,7 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
|
||||
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
|
||||
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
|
||||
// see a correct chart on their first render.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
|
||||
|
||||
@@ -106,7 +106,9 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// bad sortBy + bad sortDir + bad aggregate.
|
||||
const res = await GET(getReq("sortBy=junk&sortDir=upside&aggregate=hourly"));
|
||||
const res = await GET(
|
||||
getReq("sortBy=junk&sortDir=upside&aggregate=hourly"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
@@ -138,9 +140,7 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
describe("POST /api/measurements — single — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Missing `type` (required enum) + missing `value` (required number).
|
||||
const res = await POST(
|
||||
postReq({ measuredAt: "not-an-iso-string" }),
|
||||
);
|
||||
const res = await POST(postReq({ measuredAt: "not-an-iso-string" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
@@ -235,7 +235,9 @@ describe("POST /api/measurements — batch — 422 multi-issue (v1.4.43 W6)", ()
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("measurements.create.batch.validation-failed");
|
||||
expect(call.data.action).toBe(
|
||||
"measurements.create.batch.validation-failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
|
||||
@@ -162,7 +162,10 @@ type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
|
||||
// the strict insert-only contract.
|
||||
const STATS_EXTERNAL_ID_PREFIX = "stats:";
|
||||
function isStatsExternalId(externalId: string | null | undefined): boolean {
|
||||
return typeof externalId === "string" && externalId.startsWith(STATS_EXTERNAL_ID_PREFIX);
|
||||
return (
|
||||
typeof externalId === "string" &&
|
||||
externalId.startsWith(STATS_EXTERNAL_ID_PREFIX)
|
||||
);
|
||||
}
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
@@ -462,7 +465,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// v1.8.6 W6 — scope the overwrite to the row's own source
|
||||
// so a `stats:*` re-post only touches the matching
|
||||
// (type, source, externalId) row.
|
||||
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
|
||||
source: p.row
|
||||
.source as Prisma.MeasurementCreateManyInput["source"],
|
||||
type: p.row.type as MeasurementType,
|
||||
externalId: p.row.externalId as string,
|
||||
},
|
||||
|
||||
@@ -20,8 +20,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
|
||||
@@ -32,8 +32,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
@@ -141,11 +140,36 @@ describe("GET /api/measurements/series", () => {
|
||||
// total in hours, not a point per stage.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "s1", value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
|
||||
{ id: "s2", value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
|
||||
{ id: "s3", value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
|
||||
{ id: "s4", value: 60, measuredAt: new Date("2026-06-04T05:00:00.000Z"), sleepStage: "AWAKE" },
|
||||
{ id: "s5", value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
|
||||
{
|
||||
id: "s1",
|
||||
value: 240,
|
||||
measuredAt: new Date("2026-06-04T00:00:00.000Z"),
|
||||
sleepStage: "CORE",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
value: 90,
|
||||
measuredAt: new Date("2026-06-04T02:00:00.000Z"),
|
||||
sleepStage: "DEEP",
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
value: 80,
|
||||
measuredAt: new Date("2026-06-04T04:00:00.000Z"),
|
||||
sleepStage: "REM",
|
||||
},
|
||||
{
|
||||
id: "s4",
|
||||
value: 60,
|
||||
measuredAt: new Date("2026-06-04T05:00:00.000Z"),
|
||||
sleepStage: "AWAKE",
|
||||
},
|
||||
{
|
||||
id: "s5",
|
||||
value: 480,
|
||||
measuredAt: new Date("2026-06-03T23:00:00.000Z"),
|
||||
sleepStage: "IN_BED",
|
||||
},
|
||||
] as never);
|
||||
const res = await GET(req("kind=sleep&days=30"));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -362,11 +386,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
|
||||
const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos"));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
@@ -377,7 +403,7 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
|
||||
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
|
||||
256,
|
||||
);
|
||||
expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\"");
|
||||
expect(meta.received_shape_excerpt as string).toContain('"kind":"garbage"');
|
||||
|
||||
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
|
||||
expect(Array.isArray(issues)).toBe(true);
|
||||
@@ -392,11 +418,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
|
||||
const long = "x".repeat(500);
|
||||
const res = await GET(req(`kind=garbage&days=0&junk=${long}`));
|
||||
expect(res.status).toBe(422);
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
expect((meta.received_shape_excerpt as string).length).toBe(256);
|
||||
});
|
||||
@@ -420,11 +448,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
|
||||
@@ -152,7 +152,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// query map is routed through `redactSensitiveFields` first so any
|
||||
// future credential-shaped query string (`token`, `apiKey`,
|
||||
// `csrfState`) redacts to `"[redacted]"` before the truncate.
|
||||
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
|
||||
const payloadDiagnostic = buildPayloadDiagnostic(
|
||||
redactSensitiveFields(rawQuery),
|
||||
);
|
||||
annotate({
|
||||
action: { name: "measurements.series.validation-failed" },
|
||||
meta: {
|
||||
@@ -260,8 +262,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
at: n.measuredAt.toISOString(),
|
||||
value: Math.round((n.asleepMinutes / 60) * 100) / 100,
|
||||
secondary: null,
|
||||
sleepStages:
|
||||
Object.keys(stageHours).length > 0 ? stageHours : null,
|
||||
sleepStages: Object.keys(stageHours).length > 0 ? stageHours : null,
|
||||
};
|
||||
});
|
||||
} else if (kind === "bloodPressure") {
|
||||
|
||||
@@ -170,10 +170,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
// Re-stub deps the outer beforeEach's resetAllMocks cleared.
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
const res = await PUT(
|
||||
putReq({ endsOn: "2026-12-31" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const call = lastUpdateCall();
|
||||
expect(call.data.endsOn).toBeInstanceOf(Date);
|
||||
@@ -251,9 +248,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
PREVIOUS_ROW,
|
||||
] as never);
|
||||
@@ -379,9 +374,7 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
});
|
||||
expect(
|
||||
(arg.where.scheduledFor as { gte: Date }).gte,
|
||||
).toBeInstanceOf(Date);
|
||||
expect((arg.where.scheduledFor as { gte: Date }).gte).toBeInstanceOf(Date);
|
||||
expect(arg.data.deletedAt).toBeInstanceOf(Date);
|
||||
expect(arg.data.syncVersion).toEqual({ increment: 1 });
|
||||
});
|
||||
@@ -394,8 +387,8 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
|
||||
{ scheduledFor: new Date("2026-06-10T18:00:00Z") },
|
||||
{ scheduledFor: new Date("2026-06-11T06:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(dayKeyForScheduledFor).mockImplementation(
|
||||
(scheduledFor: Date) => scheduledFor.toISOString().slice(0, 10),
|
||||
vi.mocked(dayKeyForScheduledFor).mockImplementation((scheduledFor: Date) =>
|
||||
scheduledFor.toISOString().slice(0, 10),
|
||||
);
|
||||
|
||||
const res = await PUT(
|
||||
@@ -642,17 +635,15 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
} as never);
|
||||
const res = await PUT(
|
||||
putReq({ asNeeded: true, schedules: [] }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
const res = await PUT(putReq({ asNeeded: true, schedules: [] }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
// Wholesale replace ran: delete-all, recreate none.
|
||||
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
const updateData = vi.mocked(prisma.medication.update).mock
|
||||
.calls[0][0] as { data: Record<string, unknown> };
|
||||
const updateData = vi.mocked(prisma.medication.update).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(updateData.data.asNeeded).toBe(true);
|
||||
expect(updateData.data.schedules).toEqual({ create: [] });
|
||||
});
|
||||
@@ -695,8 +686,9 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateData = vi.mocked(prisma.medication.update).mock
|
||||
.calls[0][0] as { data: Record<string, unknown> };
|
||||
const updateData = vi.mocked(prisma.medication.update).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(updateData.data.asNeeded).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -111,9 +111,7 @@ describe("GET /api/medications/[id]/cadence", () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/cadence?days=14",
|
||||
),
|
||||
new NextRequest("http://localhost/api/medications/med-1/cadence?days=14"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -20,11 +20,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
computeNextDose,
|
||||
@@ -81,7 +77,9 @@ export const GET = apiHandler(
|
||||
// Pull intake events covering the window + a small buffer so a dose
|
||||
// logged at the edge can still pair with its slot.
|
||||
const bufferMs = 24 * 60 * 60 * 1000;
|
||||
const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs);
|
||||
const from = new Date(
|
||||
asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs,
|
||||
);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: id,
|
||||
|
||||
@@ -82,7 +82,12 @@ const TZ = "UTC";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, timezone: TZ },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
timezone: TZ,
|
||||
},
|
||||
};
|
||||
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
@@ -340,31 +345,29 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
});
|
||||
|
||||
it("35-day-interval med → steps the windows up past 7 / 30 days", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
{
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
|
||||
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "10:00",
|
||||
windowEnd: "11:00",
|
||||
timesOfDay: ["10:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: 35,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
|
||||
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "10:00",
|
||||
windowEnd: "11:00",
|
||||
timesOfDay: ["10:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: 35,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
complianceCacheKey,
|
||||
type CompliancePayload,
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import {
|
||||
cachedSwr,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -117,7 +117,9 @@ beforeEach(() => {
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/dose-history", () => {
|
||||
|
||||
@@ -20,11 +20,7 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
|
||||
@@ -17,7 +17,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.17.0 — the GET reads the caller's notificationPrefs to resolve
|
||||
// the reorder-lead-aware low-stock trigger. Default null = the
|
||||
// documented defaults (alert on, 7-day floor, 10-day lead).
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }) },
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }),
|
||||
},
|
||||
medicationDoseChange: { create: vi.fn() },
|
||||
medicationInventoryEvent: { create: vi.fn() },
|
||||
},
|
||||
|
||||
@@ -269,7 +269,12 @@ import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
const TZ = "Europe/Berlin";
|
||||
const SESSION_TZ = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const, timezone: TZ },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
timezone: TZ,
|
||||
},
|
||||
};
|
||||
const ATTR_DAY = new Date("2026-06-05T12:00:00Z");
|
||||
function at(h: number, m: number): Date {
|
||||
@@ -541,9 +546,7 @@ describe("PUT — v1.15.18 band re-attribution", () => {
|
||||
const body = (await res.json()) as {
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe(
|
||||
"medications.intake.force_slot.occupied",
|
||||
);
|
||||
expect(body.meta?.errorCode).toBe("medications.intake.force_slot.occupied");
|
||||
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -588,9 +591,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
});
|
||||
|
||||
it("an edit into taken consumes on the row (no slot move)", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake, restoreForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
// PUT lookup — pending row anchored on the 07:00 slot.
|
||||
.mockResolvedValueOnce({
|
||||
@@ -610,7 +612,10 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
skipped: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(putReq({ takenAt: at(7, 5).toISOString() }), ROUTE_CTX);
|
||||
const res = await PUT(
|
||||
putReq({ takenAt: at(7, 5).toISOString() }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
@@ -624,9 +629,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
});
|
||||
|
||||
it("an in-place time correction on an already-taken row never consumes (pre-stamp legacy rows must not retro-charge)", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake, restoreForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
// PUT lookup — a row already taken BEFORE the edit, stamp NULL
|
||||
// (pre-v1.16.10: its stock moved through the legacy hook at take
|
||||
@@ -658,9 +662,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
});
|
||||
|
||||
it("a taken → skipped edit restores the row's stamp and never consumes", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake, restoreForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "e1",
|
||||
@@ -688,9 +691,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
});
|
||||
|
||||
it("a slot-moving taken edit refunds the old row before tombstoning and consumes the converged row — net one", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake, restoreForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "e1",
|
||||
@@ -730,17 +732,16 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
expect.objectContaining({ eventId: "e2" }),
|
||||
);
|
||||
// The refund runs BEFORE the tombstone write.
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
const restoreOrder =
|
||||
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
|
||||
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update).mock
|
||||
.invocationCallOrder[0];
|
||||
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(tombstoneOrder);
|
||||
});
|
||||
|
||||
it("DELETE refunds the row's stamp before the tombstone", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake, restoreForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
@@ -768,10 +769,10 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
expect.objectContaining({ userId: "user-1", eventId: "e1" }),
|
||||
);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
const restoreOrder =
|
||||
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
|
||||
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update).mock
|
||||
.invocationCallOrder[0];
|
||||
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(tombstoneOrder);
|
||||
});
|
||||
});
|
||||
@@ -226,7 +226,10 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
}
|
||||
|
||||
it("GET surfaces TWO simultaneous validation errors", async () => {
|
||||
const res = await GET(makeRequest("status=junk&sortBy=garbage"), ROUTE_PARAMS);
|
||||
const res = await GET(
|
||||
makeRequest("status=junk&sortBy=garbage"),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
@@ -284,7 +287,9 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
expect(prisma.auditLog.create).toHaveBeenCalled();
|
||||
const actions = vi
|
||||
.mocked(prisma.auditLog.create)
|
||||
.mock.calls.map((c) => (c[0] as { data: { action: string } }).data.action);
|
||||
.mock.calls.map(
|
||||
(c) => (c[0] as { data: { action: string } }).data.action,
|
||||
);
|
||||
expect(actions).toContain("medications.intake.list.validation-failed");
|
||||
expect(actions).toContain("medications.intake.create.validation-failed");
|
||||
});
|
||||
@@ -413,9 +418,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
});
|
||||
|
||||
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
// Resolver load → scheduled med.
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
|
||||
SCHEDULED_MED as never,
|
||||
@@ -463,9 +467,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
});
|
||||
|
||||
it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => {
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
@@ -626,9 +629,8 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica
|
||||
|
||||
// Inventory consumption fires exactly as for a scheduled medication —
|
||||
// the seam is medication-level (eventId + intakeAt), no schedule input.
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -674,9 +676,8 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica
|
||||
const res = await POST(postReq({ skipped: true }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { consumeForIntake, restoreForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -100,11 +100,14 @@ const SESSION_OK = {
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
function postWithBody(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return new NextRequest(
|
||||
"http://localhost/api/medications/med-1/intake/bulk-delete",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -119,7 +122,9 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
@@ -142,10 +147,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
|
||||
new Response(null, { status: 404 }) as never,
|
||||
);
|
||||
const res = await POST(
|
||||
postWithBody({ eventIds: ["evt-1"] }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
const res = await POST(postWithBody({ eventIds: ["evt-1"] }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -226,10 +228,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
postWithBody({ eventIds: ["evt-a"] }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
const res = await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
@@ -248,9 +247,8 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
|
||||
|
||||
describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => {
|
||||
it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => {
|
||||
const { restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const { restoreForIntake } =
|
||||
await import("@/lib/medications/inventory/consumption");
|
||||
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
@@ -275,10 +273,10 @@ describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory r
|
||||
expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }),
|
||||
);
|
||||
// The refund runs BEFORE the soft-delete sweep.
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
const restoreOrder =
|
||||
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
|
||||
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
|
||||
.invocationCallOrder[0];
|
||||
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(sweepOrder);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1000 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user