From 356888134d710974476523c01e95be099ac5c227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 23:23:53 +0200 Subject: [PATCH] fix(profile): localize the email-conflict message too The 409 email-conflict branch had a safe, specific message already, but no errorCode - the account-section fallback would have had nothing to translate it through once it stopped echoing json.error verbatim. Give it profile.update.emailInUse so it renders in the active locale like every other profile-save outcome, and propagate errorCode through both route handlers even on the (non-issues) conflict path. Also updates the /api/user/profile route test for the reported scenario directly: a valid timeFormat now lands even when gender in the same PATCH is unsupported, instead of the whole request failing. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/app/api/auth/profile/route.ts | 10 +-- .../api/user/profile/__tests__/route.test.ts | 68 +++++++++++++++++-- src/app/api/user/profile/route.ts | 10 +-- src/lib/auth/profile-update.ts | 7 +- 10 files changed, 88 insertions(+), 25 deletions(-) diff --git a/messages/de.json b/messages/de.json index b0ffcfca7..a5c792758 100644 --- a/messages/de.json +++ b/messages/de.json @@ -124,7 +124,8 @@ "profile": { "update": { "nothingSaved": "Es wurde nichts gespeichert. Bitte das markierte Feld korrigieren und erneut versuchen.", - "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden." + "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.", + "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet." } } }, diff --git a/messages/en.json b/messages/en.json index 829b2e9ee..63f93944b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -124,7 +124,8 @@ "profile": { "update": { "nothingSaved": "Nothing was saved. Check the highlighted field and try again.", - "invalidBody": "The profile update could not be read." + "invalidBody": "The profile update could not be read.", + "emailInUse": "This email address is already in use." } } }, diff --git a/messages/es.json b/messages/es.json index d87b49edf..7f9d866ec 100644 --- a/messages/es.json +++ b/messages/es.json @@ -124,7 +124,8 @@ "profile": { "update": { "nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.", - "invalidBody": "No se pudo leer la actualización del perfil." + "invalidBody": "No se pudo leer la actualización del perfil.", + "emailInUse": "Esta dirección de correo ya está en uso." } } }, diff --git a/messages/fr.json b/messages/fr.json index 40c969126..4cbde5c33 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -124,7 +124,8 @@ "profile": { "update": { "nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.", - "invalidBody": "La mise à jour du profil n'a pas pu être lue." + "invalidBody": "La mise à jour du profil n'a pas pu être lue.", + "emailInUse": "Cette adresse e-mail est déjà utilisée." } } }, diff --git a/messages/it.json b/messages/it.json index be78b2f4f..22a752cb6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -124,7 +124,8 @@ "profile": { "update": { "nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.", - "invalidBody": "Impossibile leggere l'aggiornamento del profilo." + "invalidBody": "Impossibile leggere l'aggiornamento del profilo.", + "emailInUse": "Questo indirizzo email è già in uso." } } }, diff --git a/messages/pl.json b/messages/pl.json index 8ceb216a9..d22ed6408 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -124,7 +124,8 @@ "profile": { "update": { "nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.", - "invalidBody": "Nie udało się odczytać aktualizacji profilu." + "invalidBody": "Nie udało się odczytać aktualizacji profilu.", + "emailInUse": "Ten adres e-mail jest już używany." } } }, diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index c3dc7fc9c..515a3e272 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -20,14 +20,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = await applyProfileUpdate(user.id, body, getClientIp(request)); if (!result.ok) { + const meta = result.errorCode ? { errorCode: result.errorCode } : undefined; return result.issues - ? apiValidationError( - result.message, - result.issues, - result.status, - result.errorCode ? { errorCode: result.errorCode } : undefined, - ) - : apiError(result.message, result.status); + ? apiValidationError(result.message, result.issues, result.status, meta) + : apiError(result.message, result.status, meta); } annotate({ action: { name: "auth.profile.update" } }); diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index b628b0550..f771581db 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -368,13 +368,73 @@ describe("PATCH /api/user/profile", () => { expect(body.data.gender).toBe("OTHER"); }); - it("still refuses an unsupported gender, with a message that names the field", async () => { + it("still refuses an unsupported gender alone, with a message that names the field", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" })); + const res = await PATCH(req({ gender: "diverse" })); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Gender must be MALE, FEMALE or OTHER"); + const body = (await res.json()) as { + error: string; + details?: { issues?: Array<{ path: string; message: string }> }; + }; + // The top-level message is a sentence for a person — it names the + // field but never repeats the validator's own enum-literal prose. + expect(body.error).toMatch(/nothing was saved/i); + expect(body.error).toContain("gender"); expect(body.error).not.toContain("Invalid option"); + // The specific reason is relocated, not deleted. + expect(body.details?.issues?.[0]).toMatchObject({ path: "gender" }); + }); + + // Reported regression: a rejected `gender` took a sibling `timeFormat` + // edit down with it because the whole PATCH was one transaction. The + // schema has no cross-field dependency, so the valid field must now + // land even when `gender` is rejected in the same body. + it("persists a valid sibling field even when gender in the same body is unsupported", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "testuser", + displayName: null, + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + locale: null, + timeFormat: "H24", + dateFormat: "AUTO", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ timeFormat: "H24" }), + }), + ); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect("gender" in arg.data).toBe(false); + const body = (await res.json()) as { + error: string | null; + data: { + timeFormat: string; + rejectedFields?: Array<{ path: string }>; + }; + }; + expect(body.error).toBeNull(); + expect(body.data.timeFormat).toBe("H24"); + expect(body.data.rejectedFields).toEqual([ + expect.objectContaining({ path: "gender" }), + ]); }); }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index e84dd736b..d6e6f6a71 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -102,14 +102,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const result = await applyProfileUpdate(user.id, body, getClientIp(request)); if (!result.ok) { + const meta = result.errorCode ? { errorCode: result.errorCode } : undefined; return result.issues - ? apiValidationError( - result.message, - result.issues, - result.status, - result.errorCode ? { errorCode: result.errorCode } : undefined, - ) - : apiError(result.message, result.status); + ? apiValidationError(result.message, result.issues, result.status, meta) + : apiError(result.message, result.status, meta); } annotate({ action: { name: "user.profile.update" } }); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 03f3e226d..55a8d5828 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -210,7 +210,12 @@ export async function applyProfileUpdate( select: { id: true }, }); if (existing && existing.id !== userId) { - return { ok: false, status: 409, message: "Email already in use" }; + return { + ok: false, + status: 409, + message: "Email already in use", + errorCode: "profile.update.emailInUse", + }; } }