mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -64,9 +64,9 @@ vi.mock("next/headers", () => ({
|
||||
// AsyncLocalStorage `eventStorage.run` wiring intact (the
|
||||
// `apiHandler` opens an event scope inside `run()`).
|
||||
vi.mock("@/lib/logging/context", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/logging/context")
|
||||
>("@/lib/logging/context");
|
||||
const actual = await vi.importActual<typeof import("@/lib/logging/context")>(
|
||||
"@/lib/logging/context",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(actual.annotate),
|
||||
@@ -153,24 +153,18 @@ beforeEach(async () => {
|
||||
// `clearAllMocks()` to wipe call history between tests; `vi.fn()` at
|
||||
// module scope only sets the initial implementation, and the slim
|
||||
// slice test must observe a resolved value not `undefined`.
|
||||
const { ensureUserRollupsFresh } = await import(
|
||||
"@/lib/rollups/measurement-rollups"
|
||||
);
|
||||
const { probeRollupCoverage } = await import(
|
||||
"@/lib/rollups/measurement-coverage"
|
||||
);
|
||||
const { computeBpInTargetFastPath } = await import(
|
||||
"@/lib/analytics/bp-in-target-fast-path"
|
||||
);
|
||||
const { computeUserHealthScoreFastPath } = await import(
|
||||
"@/lib/analytics/health-score-fast-path"
|
||||
);
|
||||
const { computeCorrelationHypothesesFastPath } = await import(
|
||||
"@/lib/analytics/correlations-fast-path"
|
||||
);
|
||||
const { computeSummariesSlice } = await import(
|
||||
"@/lib/analytics/summaries-slice"
|
||||
);
|
||||
const { ensureUserRollupsFresh } =
|
||||
await import("@/lib/rollups/measurement-rollups");
|
||||
const { probeRollupCoverage } =
|
||||
await import("@/lib/rollups/measurement-coverage");
|
||||
const { computeBpInTargetFastPath } =
|
||||
await import("@/lib/analytics/bp-in-target-fast-path");
|
||||
const { computeUserHealthScoreFastPath } =
|
||||
await import("@/lib/analytics/health-score-fast-path");
|
||||
const { computeCorrelationHypothesesFastPath } =
|
||||
await import("@/lib/analytics/correlations-fast-path");
|
||||
const { computeSummariesSlice } =
|
||||
await import("@/lib/analytics/summaries-slice");
|
||||
|
||||
(ensureUserRollupsFresh as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
undefined,
|
||||
@@ -185,9 +179,9 @@ beforeEach(async () => {
|
||||
priorMonth: null,
|
||||
priorYear: null,
|
||||
});
|
||||
(computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
(
|
||||
computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(null);
|
||||
(
|
||||
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({ hypotheses: [] });
|
||||
|
||||
@@ -62,10 +62,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "@/app/api/auth/check-user/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface CheckUserBody {
|
||||
data: {
|
||||
|
||||
@@ -27,12 +27,7 @@ const ROOT = join(__dirname, "../..");
|
||||
const SRC = join(ROOT, "src");
|
||||
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"__tests__",
|
||||
"node_modules",
|
||||
".next",
|
||||
"generated",
|
||||
]);
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
@@ -48,8 +43,7 @@ function walk(dir: string, out: string[] = []): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const KEY_PATTERN =
|
||||
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
|
||||
const KEY_PATTERN = /(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
|
||||
|
||||
interface CallSite {
|
||||
key: string;
|
||||
@@ -98,7 +92,10 @@ function extractKeys(): CallSite[] {
|
||||
inBlockComment = true;
|
||||
break;
|
||||
}
|
||||
line = line.slice(0, start) + " ".repeat(end - start + 2) + line.slice(end + 2);
|
||||
line =
|
||||
line.slice(0, start) +
|
||||
" ".repeat(end - start + 2) +
|
||||
line.slice(end + 2);
|
||||
}
|
||||
line = stripCommentLine(line);
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -119,7 +116,11 @@ function resolveLeaf(obj: unknown, dotted: string): string | null {
|
||||
const parts = dotted.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
|
||||
if (
|
||||
cur &&
|
||||
typeof cur === "object" &&
|
||||
p in (cur as Record<string, unknown>)
|
||||
) {
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
} else {
|
||||
return null;
|
||||
|
||||
@@ -30,11 +30,7 @@ function loadBundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
}
|
||||
|
||||
function flatten(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
out: Map<string, string>,
|
||||
): void {
|
||||
function flatten(obj: unknown, prefix: string, out: Map<string, string>): void {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
|
||||
@@ -186,7 +186,11 @@ function isReferenced(leaf: string, ref: Reference): boolean {
|
||||
}
|
||||
}
|
||||
for (const ck of ref.literalKeys) {
|
||||
if (base === ck || base.startsWith(`${ck}.`) || ck.startsWith(`${base}.`)) {
|
||||
if (
|
||||
base === ck ||
|
||||
base.startsWith(`${ck}.`) ||
|
||||
ck.startsWith(`${base}.`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
});
|
||||
|
||||
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"),
|
||||
);
|
||||
const res = proxy(makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
|
||||
@@ -324,7 +324,9 @@ describe("sw.js — trimCache", () => {
|
||||
store.keysCalls = 0;
|
||||
|
||||
const trimCache = (
|
||||
harness.context as { trimCache?: (name: string, max: number) => Promise<void> }
|
||||
harness.context as {
|
||||
trimCache?: (name: string, max: number) => Promise<void>;
|
||||
}
|
||||
).trimCache;
|
||||
expect(typeof trimCache).toBe("function");
|
||||
await trimCache!(CURRENT_PAGE_CACHE, 5);
|
||||
@@ -340,10 +342,7 @@ describe("sw.js — trimCache", () => {
|
||||
});
|
||||
|
||||
// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ─────────────
|
||||
async function dispatchPush(
|
||||
harness: SwHarness,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
async function dispatchPush(harness: SwHarness, data: unknown): Promise<void> {
|
||||
let settled: Promise<unknown> = Promise.resolve();
|
||||
harness.listeners.get("push")!({
|
||||
data: { json: () => data, text: () => JSON.stringify(data) },
|
||||
|
||||
@@ -147,12 +147,8 @@ describe("dashboard chart-reveal wiring (page.tsx)", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
|
||||
it("derives the gated id list from the chart visibility gates", () => {
|
||||
expect(src).toMatch(
|
||||
/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/useDashboardChartReveal\(revealChartIds\)/,
|
||||
);
|
||||
expect(src).toMatch(/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/);
|
||||
expect(src).toMatch(/useDashboardChartReveal\(revealChartIds\)/);
|
||||
});
|
||||
|
||||
it("every data-backed chart reports through onDataReady", () => {
|
||||
|
||||
@@ -243,9 +243,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
|
||||
|
||||
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/,
|
||||
);
|
||||
expect(src).toMatch(/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/);
|
||||
});
|
||||
|
||||
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
|
||||
|
||||
@@ -68,18 +68,14 @@ describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
|
||||
const shellSource = load(
|
||||
`${process.cwd()}/src/components/layout/auth-shell.tsx`,
|
||||
);
|
||||
expect(shellSource).toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
expect(shellSource).toContain('from "@/lib/insights/coach-launch-context"');
|
||||
expect(shellSource).toMatch(/<CoachLaunchProvider>/);
|
||||
expect(shellSource).toMatch(/<LayoutCoachMount \/>/);
|
||||
});
|
||||
|
||||
it("the insights layout no longer double-mounts the provider or drawer", () => {
|
||||
const layout = load(INSIGHTS_LAYOUT_PATH);
|
||||
expect(layout).not.toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
expect(layout).not.toContain('from "@/lib/insights/coach-launch-context"');
|
||||
expect(layout).not.toContain("import { LayoutCoachMount }");
|
||||
// FAB stays scoped to `/insights/**` because the floating button
|
||||
// would distract on surfaces with a contextual inline CTA.
|
||||
|
||||
@@ -50,7 +50,7 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="space-y-3 scroll-mt-28">
|
||||
<section id={id} className="scroll-mt-28 space-y-3">
|
||||
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
@@ -64,7 +64,7 @@ function Section({
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-dvh">
|
||||
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur pt-[env(safe-area-inset-top)]">
|
||||
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3 md:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
@@ -122,8 +122,8 @@ export default function AboutPage() {
|
||||
<Section id="credits" title="Credits">
|
||||
<p>
|
||||
HealthLog stands on a number of open-source libraries and public
|
||||
data sources. The list below covers the third-party assets that
|
||||
ship with the runtime image and whose licences require an explicit
|
||||
data sources. The list below covers the third-party assets that ship
|
||||
with the runtime image and whose licences require an explicit
|
||||
attribution.
|
||||
</p>
|
||||
|
||||
@@ -150,23 +150,22 @@ export default function AboutPage() {
|
||||
GeoLite2-ASN
|
||||
</code>
|
||||
) power the offline IP-to-location and IP-to-carrier lookups that
|
||||
decorate the admin login-overview audit table. They are
|
||||
distributed under the{" "}
|
||||
decorate the admin login-overview audit table. They are distributed
|
||||
under the{" "}
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Creative Commons Attribution-ShareAlike 4.0 International
|
||||
License
|
||||
Creative Commons Attribution-ShareAlike 4.0 International License
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<footer
|
||||
className="border-border/60 mt-12 border-t pt-6 text-xs text-muted-foreground"
|
||||
className="border-border/60 text-muted-foreground mt-12 border-t pt-6 text-xs"
|
||||
data-slot="about-footer"
|
||||
>
|
||||
<p>
|
||||
|
||||
@@ -222,11 +222,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
value={achievement.progressPercent}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.criterionHint", {
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
t,
|
||||
),
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
/>
|
||||
@@ -329,8 +325,7 @@ export default function AchievementsPage() {
|
||||
{t("achievements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
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