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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 23:25:18 +02:00
1 parent 6f5935aeb4
commit 356888134d
10 files changed
+88 -25

No files matched your search

+2 -1
View File
@@ -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."
}
}
},
+2 -1
View File
@@ -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."
}
}
},
+2 -1
View File
@@ -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."
}
}
},
+2 -1
View File
@@ -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."
}
}
},
+2 -1
View File
@@ -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."
}
}
},
+2 -1
View File
@@ -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."
}
}
},
+3 -7
View File
@@ -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" } });
@@ -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<string, unknown>;
};
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" }),
]);
});
});
+3 -7
View File
@@ -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" } });
+6 -1
View File
@@ -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",
};
}
}