mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge Retry-After on every refused request, stable list pagination and the corrected error contract
This commit is contained in:
commit
6a2282fef8
80 files changed
+2261
-220
No files matched your search
@@ -32,6 +32,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -192,6 +193,7 @@ describe("PATCH /api/auth/me/diabetes", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -247,6 +248,7 @@ describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -197,6 +198,7 @@ describe("PATCH /api/auth/me/glucose-unit", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -397,6 +398,7 @@ describe("PATCH — envelope and transport", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
@@ -147,6 +147,10 @@ describe("POST /api/auth/me/mfa/disable", () => {
|
||||
} as never);
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(401);
|
||||
// The code is what separates "re-prompt for a digit" from "this session is
|
||||
// gone"; a client that branches on prose here signs the user out on a typo.
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("auth.mfa.code_invalid");
|
||||
expect(destroyOtherSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
AUTH_ERROR_CODES,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
} from "@/lib/api-handler";
|
||||
@@ -81,7 +82,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
},
|
||||
});
|
||||
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
|
||||
throw new HttpError(401, "Invalid code");
|
||||
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
|
||||
}
|
||||
|
||||
// Every cheap check has passed and the teardown is next, so spend the
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
AUTH_ERROR_CODES,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
} from "@/lib/api-handler";
|
||||
@@ -99,7 +100,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
action: { name: "auth.mfa.totp.confirm.invalid" },
|
||||
meta: { replay: result.replay },
|
||||
});
|
||||
throw new HttpError(401, "Invalid code");
|
||||
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -300,6 +301,7 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -587,6 +588,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
AUTH_ERROR_CODES,
|
||||
HttpError,
|
||||
requireAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -139,7 +144,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
async function presentedBearerToken(): Promise<string> {
|
||||
const authHeader = (await headers()).get("authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
throw new HttpError(401, "Not authenticated");
|
||||
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
|
||||
}
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ beforeEach(() => {
|
||||
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 60_000,
|
||||
ip: "1.2.3.4",
|
||||
@@ -137,6 +138,7 @@ describe("POST /api/c/[token]/unlock", () => {
|
||||
});
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
ip: "1.2.3.4",
|
||||
|
||||
@@ -55,7 +55,12 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
const RL_OK = {
|
||||
allowed: true,
|
||||
limit: 20,
|
||||
remaining: 19,
|
||||
resetAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
@@ -109,6 +114,7 @@ describe("POST /api/consent/ai", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 20,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -54,7 +54,12 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
const RL_OK = {
|
||||
allowed: true,
|
||||
limit: 20,
|
||||
remaining: 19,
|
||||
resetAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
@@ -99,6 +104,7 @@ describe("GET /api/consent/ai/latest", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 20,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -197,6 +203,7 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 20,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -43,7 +43,12 @@ const SESSION_OK = {
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
const RL_OK = {
|
||||
allowed: true,
|
||||
limit: 20,
|
||||
remaining: 19,
|
||||
resetAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
/** The mount heal posts no body and no content-type. */
|
||||
function mkHeal(): Request {
|
||||
@@ -79,6 +84,7 @@ describe("POST /api/consent/ai/web", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 20,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -90,10 +90,18 @@ describe("GET /api/custom-metrics/[id]/entries", () => {
|
||||
expect(body.data.meta.total).toBe(5);
|
||||
expect(body.data.meta.limit).toBe(1);
|
||||
const findCall = vi.mocked(prisma.customMetricEntry.findMany).mock
|
||||
.calls[0][0] as { where: Record<string, unknown>; take: number };
|
||||
.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
take: number;
|
||||
orderBy: unknown;
|
||||
};
|
||||
expect(findCall.where.userId).toBe("user-1");
|
||||
expect(findCall.where.customMetricId).toBe("cm-1");
|
||||
expect(findCall.take).toBe(1);
|
||||
// `measuredAt` is not unique on a custom metric — a batch of readings can
|
||||
// share one instant — so offset paging over it alone can repeat a row on
|
||||
// one page and drop another.
|
||||
expect(findCall.orderBy).toEqual([{ measuredAt: "desc" }, { id: "desc" }]);
|
||||
});
|
||||
|
||||
it("404s a cross-user / unknown metric", async () => {
|
||||
|
||||
@@ -62,7 +62,9 @@ export const GET = apiHandler(
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.customMetricEntry.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: sortDir },
|
||||
// Unique tiebreaker — see the measurements list: offset paging over
|
||||
// a non-unique sort key can repeat and drop rows between pages.
|
||||
orderBy: [{ measuredAt: sortDir }, { id: sortDir }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 39,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
@@ -159,6 +160,7 @@ describe("POST /api/daily/digest/dismiss", () => {
|
||||
const { checkRateLimit } = await import("@/lib/rate-limit");
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -168,6 +168,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
@@ -304,6 +305,7 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
|
||||
it("consumes the shared document-AI bucket honestly", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30 * 60_000,
|
||||
});
|
||||
|
||||
@@ -128,6 +128,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
@@ -222,6 +223,7 @@ describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
|
||||
const resetAt = Date.now() + 47 * 60_000;
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt,
|
||||
});
|
||||
@@ -309,6 +311,7 @@ describe("POST /api/documents/inbound/[id]/index — staging continuation", () =
|
||||
it("does not reach staging when the index itself was refused", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
@@ -190,6 +190,7 @@ describe("GET /api/export/measurements", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -229,6 +230,7 @@ describe("GET /api/export/measurements", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -258,6 +260,7 @@ describe("GET /api/export/measurements", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -289,6 +292,7 @@ describe("GET /api/export/measurements", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -327,6 +331,7 @@ describe("GET /api/export/medications", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -370,6 +375,7 @@ describe("GET /api/export/medications", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -390,6 +396,7 @@ describe("GET /api/export/medications", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -425,6 +432,7 @@ describe("GET /api/export/medications", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -452,6 +460,7 @@ describe("GET /api/export/mood", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -492,6 +501,7 @@ describe("GET /api/export/full-backup", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -562,6 +572,7 @@ describe("GET /api/export/full-backup", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
@@ -105,7 +105,10 @@ describe("GET /api/illness/episodes/{id}/day-logs — date-less list", () => {
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
where: { episodeId: "ep-1", deletedAt: null },
|
||||
orderBy: { date: "desc" },
|
||||
// `date` is a calendar day and repeats within an episode, so the
|
||||
// unique tiebreaker is what keeps `skip`-based paging from repeating
|
||||
// one row and dropping another between pages.
|
||||
orderBy: [{ date: "desc" }, { id: "desc" }],
|
||||
take: 60,
|
||||
skip: 0,
|
||||
});
|
||||
@@ -126,7 +129,7 @@ describe("GET /api/illness/episodes/{id}/day-logs — date-less list", () => {
|
||||
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
orderBy: { date: "asc" },
|
||||
orderBy: [{ date: "asc" }, { id: "asc" }],
|
||||
take: 10,
|
||||
skip: 20,
|
||||
});
|
||||
|
||||
@@ -88,7 +88,8 @@ export const GET = apiHandler(
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.illnessDayLog.findMany({
|
||||
where,
|
||||
orderBy: { date: sortDir },
|
||||
// Unique tiebreaker — `date` is a calendar day and repeats.
|
||||
orderBy: [{ date: sortDir }, { id: sortDir }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: dayLogSymptomInclude,
|
||||
|
||||
@@ -117,6 +117,7 @@ beforeEach(() => {
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 120,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -103,6 +103,7 @@ beforeEach(() => {
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 120,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -229,6 +230,7 @@ describe("GET /api/insights/correlations", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 120,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -188,6 +188,7 @@ beforeEach(() => {
|
||||
selfContextText.mockResolvedValue(null);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -366,6 +367,7 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
expect(limit).toBe(10);
|
||||
return {
|
||||
allowed: callCount <= 10,
|
||||
limit: 10,
|
||||
remaining: Math.max(0, 10 - callCount),
|
||||
resetAt: Date.now() + 3600_000,
|
||||
};
|
||||
@@ -392,6 +394,7 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
expect(limit).toBe(3);
|
||||
return {
|
||||
allowed: callCount <= 3,
|
||||
limit: 3,
|
||||
remaining: Math.max(0, 3 - callCount),
|
||||
resetAt: Date.now() + 3600_000,
|
||||
};
|
||||
@@ -673,6 +676,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
mockBriefinglessFreshCache();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
@@ -691,6 +695,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
mockBriefinglessFreshCache();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -114,6 +115,7 @@ describe("POST /api/internal/web-vitals", () => {
|
||||
it("returns 429 when the rate-limit fires", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -105,7 +105,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.labResult.findMany({
|
||||
where,
|
||||
orderBy: { takenAt: sortDir },
|
||||
// Unique tiebreaker: `takenAt` is a day-resolution timestamp, so a
|
||||
// panel drawn in one sitting ties across every analyte in it. Offset
|
||||
// paging over a non-unique sort key can repeat a row on one page and
|
||||
// drop another, invisibly to both sides.
|
||||
orderBy: [{ takenAt: sortDir }, { id: sortDir }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
|
||||
@@ -37,12 +37,14 @@ vi.mock("@/lib/auth/issue-token", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 100,
|
||||
resetAt: Date.now() + 60_000,
|
||||
ip: "1.2.3.4",
|
||||
})),
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
@@ -103,6 +105,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -215,6 +218,7 @@ describe("authorization_code grant", () => {
|
||||
it("rejects a replayed code (single-use jti already claimed)", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -302,11 +302,7 @@ export async function GET(request: NextRequest): Promise<Response> {
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
headers: rateLimitHeaders(rl),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -407,11 +403,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
headers: rateLimitHeaders(rl),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,11 +80,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
headers: rateLimitHeaders(rl),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,11 +222,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
headers: rateLimitHeaders(rl),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,7 +303,10 @@ describe("GET /api/measurement-reminders/[id]/history", () => {
|
||||
});
|
||||
expect(body.data.meta).toEqual({ total: 2, limit: 10, offset: 0 });
|
||||
const query = eventFindManyMock.mock.calls[0][0];
|
||||
expect(query.orderBy).toEqual({ occurredAt: "desc" });
|
||||
// The trailing `id` is the unique tiebreaker: several reminder events
|
||||
// can share one `occurredAt`, and offset paging over a tied key repeats
|
||||
// and drops rows between pages.
|
||||
expect(query.orderBy).toEqual([{ occurredAt: "desc" }, { id: "desc" }]);
|
||||
expect(query.where).toEqual({ reminderId: "r1", userId: "u1" });
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ export const GET = apiHandler(
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.measurementReminderEvent.findMany({
|
||||
where,
|
||||
orderBy: { occurredAt: "desc" },
|
||||
// Unique tiebreaker — several reminder events can share one instant.
|
||||
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
|
||||
@@ -189,6 +189,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -186,6 +186,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -159,6 +159,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(session as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -194,6 +194,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -271,6 +272,7 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", ()
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -148,6 +148,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -151,6 +151,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -190,7 +190,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: sortDir },
|
||||
// Same tiebreaker as the paged list. This one slices in memory rather
|
||||
// than with `skip`, so there is no page drift to prevent — but two
|
||||
// identical requests would still be free to render a tied block in
|
||||
// different orders, and a drill-down that reshuffles on refresh reads
|
||||
// as data changing.
|
||||
orderBy: [{ measuredAt: sortDir }, { id: sortDir }],
|
||||
// v1.4.38 — the 1000-row cap now lives on the validator (refine
|
||||
// on `(limit, dayKey)` returning 422 when a caller asks for
|
||||
// more). The route reads `limit` straight through because the
|
||||
@@ -544,7 +549,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const [measurements, total] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
// `{ id: sortDir }` is the tiebreaker, not decoration. `measuredAt`
|
||||
// is not unique here and ties are the normal case. Only the exact
|
||||
// tuple `(userId, type, measuredAt, source, sleepStage)` is unique,
|
||||
// so a tie survives across `type` (the `stats:<id>:YYYY-MM-DD`
|
||||
// daily-rollup convention puts every daily total at one instant),
|
||||
// across `source` (two writers landing on the same instant) and
|
||||
// across `sleepStage`. Without a unique secondary key Postgres is
|
||||
// free to order the tied block differently between the query for page
|
||||
// N and the query for page N+1, so a client paging its own history
|
||||
// silently gets one row twice and never gets another.
|
||||
orderBy: [{ [sortBy]: sortDir }, { id: sortDir }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
|
||||
@@ -46,6 +46,7 @@ vi.mock("@/lib/cache/server-cache", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -139,6 +140,7 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -416,6 +418,7 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
it("returns 429 when the per-user rate limit is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/medications/route-guards", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -92,6 +93,7 @@ beforeEach(() => {
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -134,6 +136,7 @@ describe("GET /api/medications/[id]/dose-history", () => {
|
||||
it("returns 429 when the per-user cap is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -85,6 +86,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -251,6 +253,7 @@ describe("POST /api/medications/[id]/glp1", () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -208,6 +208,27 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("orders on a unique tiebreaker, keeping NULLS LAST on the takenAt arm", async () => {
|
||||
// Scheduled slots share an instant by construction — a twice-daily
|
||||
// medication puts every morning dose on the same `scheduledFor` — so
|
||||
// offset paging over the sort key alone can repeat a row on one page and
|
||||
// drop another. The `takenAt` arm keeps its NULLS LAST pin as well: the
|
||||
// tiebreaker must not push skipped rows back to the top.
|
||||
await GET(makeRequest("sortBy=takenAt&sortDir=desc"), ROUTE_PARAMS);
|
||||
expect(
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mock.calls[0][0]
|
||||
?.orderBy,
|
||||
).toEqual([{ takenAt: { sort: "desc", nulls: "last" } }, { id: "desc" }]);
|
||||
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockClear();
|
||||
|
||||
await GET(makeRequest("sortBy=scheduledFor&sortDir=asc"), ROUTE_PARAMS);
|
||||
expect(
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mock.calls[0][0]
|
||||
?.orderBy,
|
||||
).toEqual([{ scheduledFor: "asc" }, { id: "asc" }]);
|
||||
});
|
||||
|
||||
it("rejects unknown status values with a 422", async () => {
|
||||
const res = await GET(makeRequest("status=junk"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock("@/lib/medications/route-guards", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -124,6 +125,7 @@ beforeEach(() => {
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -239,6 +241,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
|
||||
it("returns 429 when the per-user cap is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -97,6 +97,7 @@ beforeEach(() => {
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -407,6 +408,7 @@ describe("POST /api/medications/[id]/intake/import — future bound and limiter"
|
||||
it("refuses with 429 once the import limiter trips", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -729,10 +729,17 @@ export const GET = apiHandler(
|
||||
// the history view. Pinning them last keeps the descending order
|
||||
// reading today → yesterday → … with real timestamps first. Other
|
||||
// sort columns are non-null so they keep the simple shape.
|
||||
// The trailing `{ id: sortDir }` is the unique tiebreaker. Scheduled
|
||||
// slots share an instant by construction — a twice-daily medication puts
|
||||
// every morning dose on the same `scheduledFor` — so offset paging over
|
||||
// the primary key alone can repeat a row on one page and drop another.
|
||||
const orderBy =
|
||||
sortBy === "takenAt"
|
||||
? { takenAt: { sort: sortDir, nulls: "last" as const } }
|
||||
: { [sortBy]: sortDir };
|
||||
? [
|
||||
{ takenAt: { sort: sortDir, nulls: "last" as const } },
|
||||
{ id: sortDir },
|
||||
]
|
||||
: [{ [sortBy]: sortDir }, { id: sortDir }];
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -87,6 +88,7 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -253,6 +255,7 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -84,6 +85,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -298,6 +300,7 @@ describe("POST /api/medications/[id]/side-effects", () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/cache/server-cache", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -106,6 +107,7 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -178,6 +180,7 @@ describe("GET /api/medications/compliance", () => {
|
||||
it("returns 429 when the per-user rate limit is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -152,6 +153,7 @@ describe("POST /api/medications/extract — rate limit", () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,11 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
mentalHealthAssessment: { create: vi.fn(), findFirst: vi.fn() },
|
||||
mentalHealthAssessment: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
measurement: { create: vi.fn().mockResolvedValue({}) },
|
||||
},
|
||||
}));
|
||||
@@ -60,7 +64,7 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { GET, POST } from "../route";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -96,6 +100,7 @@ beforeEach(() => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now(),
|
||||
});
|
||||
@@ -448,3 +453,25 @@ describe("POST /api/mental-health/assessments — external-id stability floor",
|
||||
expect(prisma.mentalHealthAssessment.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/mental-health/assessments", () => {
|
||||
it("orders the history on a unique tiebreaker", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest(
|
||||
new URL("http://localhost/api/mental-health/assessments"),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// `takenAt` is day-resolution, so two instruments answered in one sitting
|
||||
// tie; without the unique secondary key offset paging can repeat a row on
|
||||
// one page and drop another.
|
||||
const findArgs = vi.mocked(prisma.mentalHealthAssessment.findMany).mock
|
||||
.calls[0][0] as { orderBy: unknown };
|
||||
expect(findArgs.orderBy).toEqual([{ takenAt: "desc" }, { id: "desc" }]);
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
deletedAt: null,
|
||||
...(instrument ? { instrument } : {}),
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
|
||||
// taken in one sitting tie.
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
take: limit ?? 100,
|
||||
skip: offset ?? 0,
|
||||
select: {
|
||||
|
||||
@@ -101,6 +101,7 @@ beforeEach(() => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -137,6 +138,7 @@ describe("POST /api/nutrients/water", () => {
|
||||
it("returns 429 when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
@@ -79,6 +80,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -243,6 +245,7 @@ describe("POST /api/onboarding/step — rate limit", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
}),
|
||||
@@ -89,6 +90,7 @@ beforeEach(() => {
|
||||
// Default rate-limit pass-through; per-test overrides drop in below.
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
});
|
||||
@@ -210,6 +212,7 @@ describe("POST /api/user/avatar", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
@@ -145,6 +145,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
@@ -218,6 +219,7 @@ describe("POST /api/workouts/batch — auth + size + rate-limit", () => {
|
||||
it("returns 429 when the rate-limit gate blocks the call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user