mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account payload; glucoseUnit reads as the closed pair the contract names, and features.trackIntake tells a separately released client that medications take the record-only switch. - Refuse a new dose for a record-only medication with medication.intake.notTracked on every dose-writing route; the bulk route reports it per entry as intake_not_tracked, and correcting an already resolved event stays open. - Answer passkey sign-in failures by name instead of a 500: passkey.challenge.expired, passkey.unknown, passkey.response.invalid and passkey.verification.failed, each with an audit row. - Carry one account-wide adherence figure in meta.aggregate on the batched compliance read, weighted by expected doses, and accept ?days=30|60|90|180|365 for a report window. - Accept ?days=30|90|180|365 on the mood insights read so stability covers the period shown, and name the window it covers. - Keep only the reminder that announces a dose off APNs when the phone owns medication reminders; follow-ups for a dose still open are pushed again, marked by a followUp flag the reminder cron sets. - Document which batch validation issues belong to an entry and pin it with a test that splits and resends a batch; publish rejectedFields items as ValidationIssue.
This commit is contained in:
1 parent
7bcb6f71d9
commit
bb563b6506
50 files changed
+1789
-142
No files matched your search
@@ -119,6 +119,14 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
|
||||
count: 1,
|
||||
reason: "read filter over whole searchParams",
|
||||
},
|
||||
"medications/compliance/route.ts": {
|
||||
count: 1,
|
||||
reason: "read filter over whole searchParams",
|
||||
},
|
||||
"mood/insights/route.ts": {
|
||||
count: 1,
|
||||
reason: "read filter over whole searchParams",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Native-client ingest and device registration. This is where the
|
||||
|
||||
@@ -74,9 +74,17 @@ import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
|
||||
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import { storedGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Capability flags for clients that are not shipped with the server. Constant
|
||||
* by design: a feature the server supports is supported for every account,
|
||||
* and a per-account answer belongs in `modules` or `ai`, not here.
|
||||
*/
|
||||
const SERVER_FEATURES = { trackIntake: true } as const;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
const { user } = auth;
|
||||
@@ -331,7 +339,8 @@ export const GET = apiHandler(async () => {
|
||||
avatarUrl: user.avatarUpdatedAt
|
||||
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
|
||||
: null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
// The stored choice, published as the closed pair the contract names.
|
||||
glucoseUnit: storedGlucoseUnit(user.glucoseUnit),
|
||||
// v1.7.0 — global metric/imperial display preference. Canonical
|
||||
// storage stays SI; this only drives the display-time transform
|
||||
// branch. Null defaults to "metric" on the client.
|
||||
@@ -442,5 +451,11 @@ export const GET = apiHandler(async () => {
|
||||
// behind it have one reader by design, and a route that projected them
|
||||
// itself would be a second statement of what a record context is.
|
||||
recordSession: recordSessionForPayload(auth),
|
||||
// v1.42 — what this server understands, for a client released on its own
|
||||
// schedule (iOS #116, item 18). `trackIntake: true` says medications take
|
||||
// the record-only switch, so an add form can offer it before the account
|
||||
// has a medication to learn it from. A flag only ever appears here once
|
||||
// the server supports it, and stays `true`; absence means an older server.
|
||||
features: SERVER_FEATURES,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* A passkey sign-in that cannot be checked answers with its own status and
|
||||
* `meta.errorCode`, never a 500. The real `verifyAuthentication` runs here
|
||||
* against a mocked database, so the test pins the whole path from the
|
||||
* library's refusal to the wire: an expired or already-used challenge, a
|
||||
* credential this server does not know, a body that is not an assertion, and
|
||||
* an assertion the WebAuthn library refuses.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
passkey: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@simplewebauthn/server", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@simplewebauthn/server")>()),
|
||||
verifyAuthenticationResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
|
||||
|
||||
const ASSERTION = {
|
||||
id: "cred-1",
|
||||
rawId: "cred-1",
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: "e30",
|
||||
authenticatorData: "AA",
|
||||
signature: "AA",
|
||||
},
|
||||
};
|
||||
|
||||
function request(credential: unknown = ASSERTION): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ challengeId: "ch-1", credential }),
|
||||
});
|
||||
}
|
||||
|
||||
async function refusal(res: Response) {
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
return { status: res.status, code: body.meta?.errorCode, data: body.data };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: 0,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ challenge: "c-1", user_id: null },
|
||||
] as never);
|
||||
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
|
||||
id: "pk-1",
|
||||
userId: "user-1",
|
||||
credentialId: "cred-1",
|
||||
credentialPublicKey: new Uint8Array([1]),
|
||||
counter: BigInt(0),
|
||||
transports: [],
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/login-verify — named refusals", () => {
|
||||
it("answers an expired or already-used challenge with 401 passkey.challenge.expired", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
expect(await refusal(await POST(request()))).toEqual({
|
||||
status: 401,
|
||||
code: "passkey.challenge.expired",
|
||||
data: null,
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.login.failed",
|
||||
expect.objectContaining({
|
||||
details: { reason: "passkey_challenge_expired" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers a challenge begun for another account the same way", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ challenge: "c-1", user_id: "someone-else" },
|
||||
] as never);
|
||||
const out = await refusal(await POST(request()));
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.code).toBe("passkey.challenge.expired");
|
||||
});
|
||||
|
||||
it("answers a passkey that is not registered here with 404 passkey.unknown", async () => {
|
||||
vi.mocked(prisma.passkey.findUnique).mockResolvedValue(null as never);
|
||||
expect(await refusal(await POST(request()))).toEqual({
|
||||
status: 404,
|
||||
code: "passkey.unknown",
|
||||
data: null,
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.login.failed",
|
||||
expect.objectContaining({ details: { reason: "passkey_unknown" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers a body that is not an assertion with 422 passkey.response.invalid", async () => {
|
||||
const out = await refusal(await POST(request({ id: "cred-1" })));
|
||||
expect(out.status).toBe(422);
|
||||
expect(out.code).toBe("passkey.response.invalid");
|
||||
});
|
||||
|
||||
it("answers an assertion the library refuses with 401 passkey.verification.failed", async () => {
|
||||
vi.mocked(verifyAuthenticationResponse).mockRejectedValue(
|
||||
new Error("Unexpected authentication response origin"),
|
||||
);
|
||||
const out = await refusal(await POST(request()));
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.code).toBe("passkey.verification.failed");
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets a real fault surface as one", async () => {
|
||||
vi.mocked(prisma.passkey.findUnique).mockRejectedValue(
|
||||
new Error("connection reset"),
|
||||
);
|
||||
const res = await POST(request());
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import {
|
||||
PasskeyAuthError,
|
||||
verifyAuthentication,
|
||||
type PasskeyAuthErrorKind,
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
@@ -20,6 +24,43 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
|
||||
|
||||
const PASSKEY_VERIFICATION_FAILED_CODE = "passkey.verification.failed";
|
||||
|
||||
/**
|
||||
* How each passkey refusal answers. The challenge and verification cases are
|
||||
* 401 (the sign-in did not prove anything), the unknown credential is 404 (the
|
||||
* passkey is not registered on this server), and a malformed body is 422.
|
||||
*/
|
||||
const PASSKEY_REFUSALS: Record<
|
||||
PasskeyAuthErrorKind,
|
||||
{ status: number; errorCode: string; message: string; auditReason: string }
|
||||
> = {
|
||||
challenge_expired: {
|
||||
status: 401,
|
||||
errorCode: "passkey.challenge.expired",
|
||||
message: "The sign-in took too long or was already used. Try again.",
|
||||
auditReason: "passkey_challenge_expired",
|
||||
},
|
||||
unknown_passkey: {
|
||||
status: 404,
|
||||
errorCode: "passkey.unknown",
|
||||
message: "This passkey is not registered here.",
|
||||
auditReason: "passkey_unknown",
|
||||
},
|
||||
malformed: {
|
||||
status: 422,
|
||||
errorCode: "passkey.response.invalid",
|
||||
message: "The passkey response could not be read.",
|
||||
auditReason: "passkey_response_invalid",
|
||||
},
|
||||
verification_failed: {
|
||||
status: 401,
|
||||
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
|
||||
message: "Passkey verification failed",
|
||||
auditReason: "passkey_verification_failed",
|
||||
},
|
||||
};
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login — a
|
||||
// credential minted before SSO was mandated must not stay a live bypass.
|
||||
@@ -55,17 +96,39 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("challengeId and credential required", 422);
|
||||
}
|
||||
|
||||
const { verification, passkey } = await verifyAuthentication(
|
||||
challengeId,
|
||||
credential,
|
||||
);
|
||||
let outcome: Awaited<ReturnType<typeof verifyAuthentication>>;
|
||||
try {
|
||||
outcome = await verifyAuthentication(challengeId, credential);
|
||||
} catch (err) {
|
||||
// A known client-side case — an expired challenge, a passkey that is no
|
||||
// longer registered here, a malformed or refused assertion — answers
|
||||
// with its own status and code (iOS #116, item 20) so the app can tell
|
||||
// the person what to do instead of reporting a server error. Anything
|
||||
// else is a real fault and keeps propagating.
|
||||
if (!(err instanceof PasskeyAuthError)) throw err;
|
||||
const refusal = PASSKEY_REFUSALS[err.kind];
|
||||
await auditLog("auth.login.failed", {
|
||||
ipAddress: ip,
|
||||
details: { reason: refusal.auditReason },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.login.passkey.refused" },
|
||||
meta: { reason: err.kind },
|
||||
});
|
||||
return apiError(refusal.message, refusal.status, {
|
||||
errorCode: refusal.errorCode,
|
||||
});
|
||||
}
|
||||
const { verification, passkey } = outcome;
|
||||
|
||||
if (!verification.verified) {
|
||||
await auditLog("auth.login.failed", {
|
||||
ipAddress: ip,
|
||||
details: { reason: "passkey_verification_failed" },
|
||||
});
|
||||
return apiError("Passkey verification failed", 401);
|
||||
return apiError("Passkey verification failed", 401, {
|
||||
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -577,4 +577,22 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => {
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it("refuses a dose for a record-only medication with medication.intake.notTracked", async () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
name: "Metformin",
|
||||
trackIntake: false,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-record-001" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("medication.intake.notTracked");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
import { refuseUntrackedIntake } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const RATE_LIMIT = 60;
|
||||
@@ -224,6 +226,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("API endpoint for this medication is disabled", 403);
|
||||
}
|
||||
|
||||
// A record-only medication takes no new dose — the same refusal every
|
||||
// dose-writing route gives (iOS #116, item 15). Checked after the scope
|
||||
// gate so a token without this medication's scope learns nothing about it.
|
||||
if (isRecordOnly(medication)) return refuseUntrackedIntake();
|
||||
|
||||
// The external ingest path runs without a User record in scope;
|
||||
// resolve the timezone once — band attribution and the compliance
|
||||
// recompute below both anchor on the user's local day.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* `POST /api/measurements/batch` — which issue belongs to which entry.
|
||||
*
|
||||
* A whole-batch 422 (`measurement.batch.invalid`) lists every problem in
|
||||
* `details.issues`. The native client splits such a batch: it drops the
|
||||
* entries the issues name and resends the rest, so one bad reading does not
|
||||
* cost the valid ones. That only works if every problem caused by one entry
|
||||
* carries a path that starts `entries.<n>`, and if the problems that are not
|
||||
* about an entry (the wrapper itself) never do. Both halves are pinned here,
|
||||
* and the first case proves the split by resending what is left.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
return (fn as (tx: unknown) => unknown)(prisma);
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: vi.fn(
|
||||
async (_tx: unknown, input: Record<string, unknown>) => ({
|
||||
status: "inserted",
|
||||
row: { id: "row-1", ...input },
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
|
||||
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/emit-shared", () => ({
|
||||
emitDataArrival: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
function post(body: unknown) {
|
||||
return POST(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const AT = new Date(Date.now() - 3_600_000).toISOString();
|
||||
|
||||
function entry(i: number, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value: 64,
|
||||
unit: "count/min",
|
||||
startDate: AT,
|
||||
endDate: AT,
|
||||
externalId: `uuid-${i}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type Issue = { path: string; code: string };
|
||||
|
||||
async function issuesFor(body: unknown): Promise<Issue[]> {
|
||||
const res = await post(body);
|
||||
expect(res.status).toBe(422);
|
||||
const json = (await res.json()) as {
|
||||
details: { issues: Issue[] };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(json.meta?.errorCode).toBe("measurement.batch.invalid");
|
||||
return json.details.issues;
|
||||
}
|
||||
|
||||
/** The entry index an issue names, or null when it names the batch itself. */
|
||||
function entryIndex(path: string): number | null {
|
||||
const m = /^entries\.(\d+)(?:\.|$)/.exec(path);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** The paths that are about the batch wrapper and no single entry. */
|
||||
const BATCH_LEVEL_PATHS = new Set(["", "entries", "syncTrigger"]);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — every entry problem names its entry", () => {
|
||||
it("names each bad entry as entries.<n>, so the rest of the batch can be resent", async () => {
|
||||
const entries = [
|
||||
entry(0),
|
||||
entry(1, { startDate: "yesterday" }),
|
||||
entry(2),
|
||||
entry(3, { value: "64" }),
|
||||
"not an entry",
|
||||
entry(5, { hkIdentifier: "", unit: "" }),
|
||||
entry(6),
|
||||
];
|
||||
const issues = await issuesFor({ entries });
|
||||
|
||||
expect(issues.length).toBeGreaterThan(0);
|
||||
for (const issue of issues) {
|
||||
expect(entryIndex(issue.path), issue.path).not.toBeNull();
|
||||
}
|
||||
const bad = new Set(issues.map((i) => entryIndex(i.path)));
|
||||
expect([...bad].sort()).toEqual([1, 3, 4, 5]);
|
||||
|
||||
// The split the client performs: drop the named entries, resend the rest.
|
||||
const kept = entries.filter((_, i) => !bad.has(i));
|
||||
const res = await post({ entries: kept });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("names the batch itself, never an entry, when the wrapper is wrong", async () => {
|
||||
const cases: unknown[] = [
|
||||
[],
|
||||
{ entries: [] },
|
||||
{ entries: "x" },
|
||||
{ entries: [entry(0)], syncTrigger: "sometimes" },
|
||||
];
|
||||
let seen = 0;
|
||||
for (const body of cases) {
|
||||
for (const issue of await issuesFor(body)) {
|
||||
expect(BATCH_LEVEL_PATHS.has(issue.path), issue.path).toBe(true);
|
||||
expect(entryIndex(issue.path)).toBeNull();
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
expect(seen).toBeGreaterThanOrEqual(cases.length);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildCompliancePayload,
|
||||
complianceCacheKey,
|
||||
type CompliancePayload,
|
||||
type CompliancePayloadWire,
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -113,6 +114,16 @@ export const GET = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(payload);
|
||||
// The report windows ride the cached cell for the batched read only; this
|
||||
// route's published shape does not carry them.
|
||||
const wire: CompliancePayloadWire = {
|
||||
applicable: payload.applicable,
|
||||
notApplicableReason: payload.notApplicableReason,
|
||||
compliance7: payload.compliance7,
|
||||
compliance30: payload.compliance30,
|
||||
dailyCompliance: payload.dailyCompliance,
|
||||
complianceDisplay: payload.complianceDisplay,
|
||||
};
|
||||
return apiSuccess(wire);
|
||||
},
|
||||
);
|
||||
@@ -71,6 +71,7 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
assertMedicationTakesIntake: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
|
||||
@@ -79,6 +79,7 @@ vi.mock("@/lib/medications/lifecycle", () => ({
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
assertMedicationTakesIntake: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
assertMedicationOwnership,
|
||||
assertMedicationTakesIntake,
|
||||
} from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
@@ -111,8 +114,11 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. This is a
|
||||
// dose write, so the guard also refuses a record-only medication with
|
||||
// `medication.intake.notTracked` (iOS #116, item 15): its history stays,
|
||||
// but a new dose would be stored and then counted nowhere.
|
||||
const guard = await assertMedicationTakesIntake(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
|
||||
@@ -274,4 +274,70 @@ describe("GET /api/medications/compliance", () => {
|
||||
);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries one account-wide figure in meta.aggregate, from applicable medications only", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medication("med-1"),
|
||||
medication("med-2"),
|
||||
{
|
||||
...medication("mirror-1"),
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: "hk-concept-1",
|
||||
schedules: [],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
const body = await res.json();
|
||||
const items = body.data as Array<{
|
||||
compliance7: { totalExpected: number; missed: number };
|
||||
compliance30: { totalExpected: number };
|
||||
}>;
|
||||
expect(body.meta.aggregate.medicationCount).toBe(2);
|
||||
// Summed over the two scheduled medications; the mirror adds nothing.
|
||||
expect(body.meta.aggregate.compliance7.totalExpected).toBe(
|
||||
items[0].compliance7.totalExpected + items[1].compliance7.totalExpected,
|
||||
);
|
||||
expect(body.meta.aggregate.compliance30.totalExpected).toBe(
|
||||
items[0].compliance30.totalExpected + items[1].compliance30.totalExpected,
|
||||
);
|
||||
expect(body.meta.aggregate.compliance7.totalExpected).toBeGreaterThan(0);
|
||||
expect(body.meta).not.toHaveProperty("days");
|
||||
});
|
||||
|
||||
it("answers meta.aggregate null when no medication's adherence applies", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({ aggregate: null });
|
||||
});
|
||||
|
||||
it("adds a complianceN block per medication and to the aggregate on ?days=N", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medication("med-1"),
|
||||
] as never);
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/medications/compliance?days=90"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const item = body.data[0] as Record<string, { totalExpected: number }>;
|
||||
expect(item.compliance90.totalExpected).toBeGreaterThan(
|
||||
item.compliance30.totalExpected,
|
||||
);
|
||||
expect(body.meta.days).toBe(90);
|
||||
expect(body.meta.aggregate.compliance90).toEqual(item.compliance90);
|
||||
});
|
||||
|
||||
it("refuses a window the ledger does not serve", async () => {
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/medications/compliance?days=45"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,22 @@
|
||||
import pLimit from "p-limit";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccessWithMeta,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
aggregateCompliance,
|
||||
buildCompliancePayload,
|
||||
complianceCacheKey,
|
||||
type CompliancePayload,
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import { COMPLIANCE_REPORT_DAYS } from "@/lib/analytics/compliance";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
@@ -28,9 +35,29 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
* wire shape — the cards render rates / streak / display block only; the
|
||||
* detail page keeps the per-id route for the grid.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
/**
|
||||
* `?days=N` adds a `complianceN` block per medication and to the account
|
||||
* aggregate, for a report that covers its own window rather than the fixed 7
|
||||
* and 30 days. Only the windows the ledger serves are accepted; anything else
|
||||
* is a 422, never a silently rounded window.
|
||||
*/
|
||||
const querySchema = z.object({
|
||||
days: z
|
||||
.enum(COMPLIANCE_REPORT_DAYS.map(String) as [string, ...string[]])
|
||||
.transform(Number)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: Request) => {
|
||||
const { user, actor } = await requireRecordAuth("read", "medications");
|
||||
|
||||
const query = querySchema.safeParse(
|
||||
Object.fromEntries(new URL(request.url).searchParams),
|
||||
);
|
||||
if (!query.success) return returnAllZodIssues(query.error, 422);
|
||||
const days = query.data.days as
|
||||
(typeof COMPLIANCE_REPORT_DAYS)[number] | undefined;
|
||||
|
||||
// v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to
|
||||
// the record. Two reasons, and both are the same reason from opposite ends:
|
||||
// a delegate hammering this route must burn their own allowance rather than
|
||||
@@ -74,7 +101,7 @@ export const GET = apiHandler(async () => {
|
||||
// on a cold cache. Warm / stale cells return without touching the
|
||||
// database at all; `Promise.all` keeps the response in list order.
|
||||
const limit = pLimit(3);
|
||||
const items = await Promise.all(
|
||||
const results = await Promise.all(
|
||||
medications.map((medication) =>
|
||||
limit(async () => {
|
||||
const payload = await cachedSwr(
|
||||
@@ -83,22 +110,69 @@ export const GET = apiHandler(async () => {
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
return {
|
||||
medicationId: medication.id,
|
||||
applicable: payload.applicable,
|
||||
notApplicableReason: payload.notApplicableReason,
|
||||
compliance7: payload.compliance7,
|
||||
compliance30: payload.compliance30,
|
||||
complianceDisplay: payload.complianceDisplay,
|
||||
};
|
||||
return { payload, medicationId: medication.id };
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// A cell cached before the report windows existed carries none; it reads
|
||||
// as the not-applicable placeholder until its next rebuild rather than
|
||||
// failing the whole read.
|
||||
const windowFor = (payload: CompliancePayload, n: number) =>
|
||||
payload.reportWindows?.[n as keyof CompliancePayload["reportWindows"]];
|
||||
|
||||
const items = results.map(({ payload, medicationId }) => ({
|
||||
medicationId,
|
||||
applicable: payload.applicable,
|
||||
notApplicableReason: payload.notApplicableReason,
|
||||
compliance7: payload.compliance7,
|
||||
compliance30: payload.compliance30,
|
||||
...(days !== undefined && days !== 30
|
||||
? { [`compliance${days}`]: windowFor(payload, days) ?? null }
|
||||
: {}),
|
||||
complianceDisplay: payload.complianceDisplay,
|
||||
}));
|
||||
|
||||
// The account-wide figure the widget ring and the report show, built only
|
||||
// from medications whose adherence means something (`applicable`).
|
||||
const applicable = results
|
||||
.map((r) => r.payload)
|
||||
.filter((payload) => payload.applicable);
|
||||
const reportWindow =
|
||||
days !== undefined && days !== 30
|
||||
? applicable.map((payload) => windowFor(payload, days))
|
||||
: [];
|
||||
const aggregate =
|
||||
applicable.length === 0
|
||||
? null
|
||||
: {
|
||||
compliance7: aggregateCompliance(
|
||||
applicable.map((p) => p.compliance7),
|
||||
),
|
||||
compliance30: aggregateCompliance(
|
||||
applicable.map((p) => p.compliance30),
|
||||
),
|
||||
...(days !== undefined && days !== 30
|
||||
? {
|
||||
[`compliance${days}`]: reportWindow.every(Boolean)
|
||||
? aggregateCompliance(
|
||||
reportWindow as NonNullable<
|
||||
(typeof reportWindow)[number]
|
||||
>[],
|
||||
)
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
medicationCount: applicable.length,
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.compliance_summary.read" },
|
||||
meta: { count: items.length },
|
||||
meta: { count: items.length, days: days ?? null },
|
||||
});
|
||||
|
||||
return apiSuccess(items);
|
||||
return apiSuccessWithMeta(items, {
|
||||
aggregate,
|
||||
...(days !== undefined ? { days } : {}),
|
||||
});
|
||||
});
|
||||
@@ -381,6 +381,45 @@ describe("POST /api/medications/intake", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("refuses to resolve an open dose of a record-only medication with medication.intake.notTracked", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
|
||||
medication: { trackIntake: false },
|
||||
} as never);
|
||||
const res = await POST(req({ intakeId: "e1", status: "taken" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("medication.intake.notTracked");
|
||||
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets the owner correct a dose a record-only medication already has", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
takenAt: new Date("2026-05-18T10:05:00.000Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
|
||||
medication: { trackIntake: false },
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
|
||||
id: "e1",
|
||||
skipped: true,
|
||||
takenAt: null,
|
||||
} as never);
|
||||
const res = await POST(req({ intakeId: "e1", status: "skipped" }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("marks event as skipped", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
|
||||
@@ -244,6 +244,29 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("skips an entry for a record-only medication as intake_not_tracked and keeps the rest", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1", trackIntake: false },
|
||||
] as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:00.000Z",
|
||||
takenAt: "2026-06-15T05:01:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.entries).toEqual([
|
||||
{ index: 0, status: "skipped", reason: "intake_not_tracked" },
|
||||
]);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
|
||||
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
|
||||
// echo (no takenAt, skipped false) — must NOT clear takenAt.
|
||||
|
||||
@@ -86,6 +86,10 @@ import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import {
|
||||
INTAKE_NOT_TRACKED_REASON,
|
||||
isRecordOnly,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -256,6 +260,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
externalSource: true,
|
||||
trackIntake: true,
|
||||
},
|
||||
});
|
||||
const ownedSet = new Set(ownedMedications.map((m) => m.id));
|
||||
@@ -366,6 +371,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A record-only medication takes no new dose (iOS #116, item 15). The
|
||||
// single-dose routes answer 422 `medication.intake.notTracked`; here the
|
||||
// same decision is one entry's outcome, so the rest of the batch lands.
|
||||
if (isRecordOnly(medById.get(entry.medicationId) ?? {})) {
|
||||
const reason = INTAKE_NOT_TRACKED_REASON;
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both dedup keys on this route are only idempotent while the value
|
||||
// is STABLE across client launches: `(userId, externalId)` for the
|
||||
// Apple dose event, and the `@unique` `idempotency_key` column for
|
||||
|
||||
@@ -54,7 +54,11 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import {
|
||||
TRACKED_INTAKE_EVENT_WHERE,
|
||||
isRecordOnly,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { refuseUntrackedIntake } from "@/lib/medications/route-guards";
|
||||
|
||||
// The query + body schemas moved to `@/lib/validations/medication` when this
|
||||
// endpoint joined the published contract: the OpenAPI registry generates from
|
||||
@@ -265,6 +269,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
trackIntake: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -273,6 +278,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Intake event not found", 404);
|
||||
}
|
||||
|
||||
// A record-only medication keeps the doses it already has, and an owner may
|
||||
// still correct one of those (that is an edit of history). What it no longer
|
||||
// takes is a NEW dose: resolving or snoozing an event that is still open
|
||||
// would record one, and it would then be counted nowhere (iOS #116, item
|
||||
// 15). Refused by name, the same code the per-medication route answers with.
|
||||
const stillOpen = existing.takenAt === null && !existing.skipped;
|
||||
if (stillOpen && isRecordOnly(existing.medication)) {
|
||||
return refuseUntrackedIntake();
|
||||
}
|
||||
|
||||
// v1.36.1 — this route is admitted for delegates because marking a dose is
|
||||
// the thing a person standing next to the patient needs to do. It is an
|
||||
// UPDATE, though, and two of the transitions it can express are not that.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* `GET /api/mood/insights?days=N` serves the stability score for the period
|
||||
* a client shows. The aggregate is cached once per user with every window in
|
||||
* it; the route picks one, names it, and keeps the per-window map off the
|
||||
* wire.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cachedSwr: vi.fn(),
|
||||
caches: { moodInsights: {} },
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const year = { score: 40, stdDev: 0.9, band: "variable", days: 300 };
|
||||
const month = { score: 90, stdDev: 0.2, band: "verySteady", days: 28 };
|
||||
|
||||
const AGGREGATE = {
|
||||
summary: { totalEntries: 300 },
|
||||
heatmap: { windowDays: 365, cells: [] },
|
||||
tags: [],
|
||||
stability: year,
|
||||
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
|
||||
};
|
||||
|
||||
const call = (query = "") =>
|
||||
(GET as (req: Request) => Promise<Response>)(
|
||||
new Request(`http://localhost/api/mood/insights${query}`),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "u", role: "USER", timezone: "UTC" },
|
||||
} as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(cachedSwr).mockResolvedValue(AGGREGATE as never);
|
||||
});
|
||||
|
||||
describe("GET /api/mood/insights — stability per period", () => {
|
||||
it("serves the whole year by default and names the window", async () => {
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.stability).toEqual(year);
|
||||
expect(body.data.stabilityWindowDays).toBe(365);
|
||||
expect(body.data).not.toHaveProperty("stabilityByWindow");
|
||||
});
|
||||
|
||||
it("serves the chosen window as stability on ?days=30", async () => {
|
||||
const body = await (await call("?days=30")).json();
|
||||
expect(body.data.stability).toEqual(month);
|
||||
expect(body.data.stabilityWindowDays).toBe(30);
|
||||
});
|
||||
|
||||
it("answers null, not the year's score, when the window has too few days", async () => {
|
||||
const body = await (await call("?days=90")).json();
|
||||
expect(body.data.stability).toBeNull();
|
||||
expect(body.data.stabilityWindowDays).toBe(90);
|
||||
});
|
||||
|
||||
it("refuses a window it does not offer", async () => {
|
||||
const res = await call("?days=45");
|
||||
expect(res.status).toBe(422);
|
||||
expect(cachedSwr).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
fetchMoodAggregates,
|
||||
MOOD_STABILITY_WINDOWS,
|
||||
type MoodAggregates,
|
||||
type MoodStabilityWindow,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
@@ -26,9 +30,28 @@ export const dynamic = "force-dynamic";
|
||||
* aggregate is served immediately and a single background recompute
|
||||
* warms a fresh one, so an active logger never re-pays the cold compute.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
/**
|
||||
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
|
||||
* the steadiness of the period it shows. Without it the score covers the
|
||||
* whole year, as it always has; `stabilityWindowDays` names the window the
|
||||
* served score covers either way.
|
||||
*/
|
||||
const querySchema = z.object({
|
||||
days: z
|
||||
.enum(MOOD_STABILITY_WINDOWS.map(String) as [string, ...string[]])
|
||||
.transform((v) => Number(v) as MoodStabilityWindow)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const query = querySchema.safeParse(
|
||||
Object.fromEntries(new URL(request.url).searchParams),
|
||||
);
|
||||
if (!query.success) return returnAllZodIssues(query.error, 422);
|
||||
const stabilityWindowDays: MoodStabilityWindow = query.data.days ?? 365;
|
||||
|
||||
// Per-domain gate: the mood-insights aggregate surface only serves the
|
||||
// mood module's analysis page, so it must be gated on the mood module
|
||||
// (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled.
|
||||
@@ -51,5 +74,17 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
// The per-window map is the cache's, not the wire's: the chosen window is
|
||||
// served as `stability`. An aggregate cached before the map existed has
|
||||
// only the year, so a shorter window reads as not yet computed (`null`)
|
||||
// until the next rebuild rather than as the year's score under a new name.
|
||||
const { stabilityByWindow, ...wire } = result;
|
||||
return apiSuccess({
|
||||
...wire,
|
||||
stability:
|
||||
stabilityWindowDays === 365
|
||||
? result.stability
|
||||
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
|
||||
stabilityWindowDays,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user