Merge Retry-After on every refused request, stable list pagination and the corrected error contract

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 05:10:05 +02:00
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();
});
});
+2 -1
View File
@@ -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,
});
+7 -2
View File
@@ -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,
});
+5 -1
View File
@@ -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,
});
+2 -10
View File
@@ -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),
},
);
}
+1 -5
View File
@@ -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),
},
);
}
+1 -5
View File
@@ -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,
});
+17 -2
View File
@@ -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,
});
+9 -2
View File
@@ -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,
});