mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(api): return 409 on duplicate-timestamp measurement edit
The PUT /api/measurements/[id] handler dispatched the Prisma
update without a try/catch, so re-pointing measuredAt onto an
existing (userId, type, measuredAt, source, sleepStage) tuple
bubbled the P2002 violation as a 500 and the row-edit Sheet
rendered the generic save-error toast.
Mirror the POST handler's P2002 catch: return a clean 409 with a
stable `meta.errorCode = "measurement.duplicate_timestamp"` and a
new measurements.duplicateTimestamp i18n key across the six
locales. The mutation in measurement-list.tsx reads the error
code from the response envelope and surfaces the localised
banner instead of the upstream English fallback.
iOS contract — PATCH /api/measurements/{id}: additive. The 200
happy-path response shape (MeasurementWireDTO), the 422
validation path and the updateMeasurementSchema are untouched.
The new 409 path is strictly clearer than the 500 it replaces.
This commit is contained in:
1 parent
948fcd9318
commit
538b44f74d
9 files changed
+213
-10
No files matched your search
@@ -331,6 +331,7 @@
|
||||
"notesPlaceholder": "z.B. nach dem Essen",
|
||||
"formReset": "Formular zurücksetzen",
|
||||
"saveError": "Fehler beim Speichern",
|
||||
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
|
||||
"deleteError": "Fehler beim Löschen",
|
||||
"invalidValue": "Bitte einen gültigen Wert eingeben.",
|
||||
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
|
||||
|
||||
@@ -331,6 +331,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
|
||||
@@ -331,6 +331,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
|
||||
@@ -331,6 +331,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
|
||||
@@ -331,6 +331,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
|
||||
@@ -331,6 +331,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { updateMeasurementSchema } from "@/lib/validations/measurement";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -62,14 +63,35 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const measurement = await prisma.measurement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.value !== undefined && { value: data.value }),
|
||||
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
|
||||
...(data.notes !== undefined && { notes: data.notes }),
|
||||
},
|
||||
});
|
||||
let measurement;
|
||||
try {
|
||||
measurement = await prisma.measurement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.value !== undefined && { value: data.value }),
|
||||
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
|
||||
...(data.notes !== undefined && { notes: data.notes }),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// v1.4.28 FB-B1 — re-pointing `measuredAt` onto an existing
|
||||
// `(userId, type, measuredAt, source, sleepStage)` tuple raises
|
||||
// `P2002`. Mirror the POST handler's catch so the row-edit Sheet
|
||||
// surfaces a clean 409 with a translatable `errorCode` instead of
|
||||
// the bare 500 the UI used to render as the generic save-error
|
||||
// toast.
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return apiError(
|
||||
"A measurement with this timestamp already exists",
|
||||
409,
|
||||
{ errorCode: "measurement.duplicate_timestamp" },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await auditLog("measurement.update", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
|
||||
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 { PUT } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putRequest(id: string, body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/measurements/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
|
||||
it("returns 409 when re-pointing measuredAt onto an existing tuple", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m-2",
|
||||
userId: "user-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1234,
|
||||
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "count",
|
||||
} as never);
|
||||
|
||||
// Mirror what Prisma raises on a unique-constraint collision.
|
||||
const p2002 = new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "x",
|
||||
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
|
||||
},
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-2", { measuredAt: "2026-05-10T09:00:00.000Z" }),
|
||||
{ params: Promise.resolve({ id: "m-2" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as { error: string; meta?: { errorCode?: string } };
|
||||
expect(json.error).toMatch(/already exists|duplicate/i);
|
||||
expect(json.meta?.errorCode).toBe("measurement.duplicate_timestamp");
|
||||
});
|
||||
|
||||
it("re-throws any non-P2002 Prisma error", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m-3",
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
measuredAt: new Date(),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "kg",
|
||||
} as never);
|
||||
|
||||
const otherError = new Prisma.PrismaClientKnownRequestError(
|
||||
"Record not found",
|
||||
{ code: "P2025", clientVersion: "x" },
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(otherError);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-3", { value: 76 }),
|
||||
{ params: Promise.resolve({ id: "m-3" }) },
|
||||
);
|
||||
|
||||
// apiHandler converts uncaught errors to 500.
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns the updated measurement on the 200 happy path", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m-4",
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "kg",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.update).mockResolvedValue({
|
||||
id: "m-4",
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
value: 76,
|
||||
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "kg",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-4", { value: 76 }),
|
||||
{ params: Promise.resolve({ id: "m-4" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { data: { id: string; value: number } };
|
||||
expect(json.data.id).toBe("m-4");
|
||||
expect(json.data.value).toBe(76);
|
||||
});
|
||||
});
|
||||
@@ -232,9 +232,24 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
}),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
const json = (await res.json()) as {
|
||||
error?: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error ?? "Update failed");
|
||||
// v1.4.28 FB-B1 — the PUT route now returns a 409 with
|
||||
// `meta.errorCode === "measurement.duplicate_timestamp"` when
|
||||
// the edit collides with an existing row's
|
||||
// `(type, measuredAt, source, sleepStage)` tuple. Forward the
|
||||
// code alongside the message so `onError` can pick the
|
||||
// localised string.
|
||||
const err = new Error(json.error ?? "Update failed") as Error & {
|
||||
errorCode?: string;
|
||||
status?: number;
|
||||
};
|
||||
err.errorCode = json.meta?.errorCode;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -243,6 +258,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
setEditError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errWithCode = err as Error & { errorCode?: string };
|
||||
if (errWithCode.errorCode === "measurement.duplicate_timestamp") {
|
||||
setEditError(t("measurements.duplicateTimestamp"));
|
||||
return;
|
||||
}
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : t("measurements.saveError"),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user