Files
EpicNext-Cms/src/i18n/auth-messages.test.ts
T
openhands 6bffc53779
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
refactor(auth): merge the duplicate login form and localize the auth screens
`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical
components. Delete the former and give `LoginForm` a `variant` prop:

- `variant="page"`   sr-only labels plus the register/forgot footer (/login)
- `variant="compact"` visible labels, no footer (homepage sidebar)

Field ids now come from `useId()`, so the two usages can never collide, and the
hardcoded "Show"/"Hide"/"Loading" strings are translated.

Localization of the login and register screens:

- `home-login-form.tsx` was entirely hardcoded English.
- `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong".
- `register.ts` returned only English strings. It now returns a
  locale-independent `code` next to the message, and the form renders
  `t(code)` with the English string as a fallback.
- Backfilled the new keys across all 25 locales, plus the login/register
  strings that were still English in most of them. `ar`, `fi` and `ja` had
  their entire login/register namespace in English and are now filled in.
  Locale parity stays at 0 missing keys, as `i18n:check` requires.

Copy that did not match the enforced rules: the UI advertised "min 8 chars"
(EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an
uppercase, a lowercase, a digit and a special character. Corrected in every
locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to
match registration.

Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on
any field. All three are now present, and error banners are announced with
`role="alert"`.

Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode`
resolves to a non-empty message in all 25 locales; verified it fails when a key
is removed. The existing register tests now also assert the error `code`.
2026-10-04 18:50:23 +02:00

175 lines
4.5 KiB
TypeScript

import { readdirSync } from "node:fs";
import { createTranslator } from "next-intl";
import { describe, expect, it } from "vitest";
import ar from "@/messages/ar.json";
import bg from "@/messages/bg.json";
import cs from "@/messages/cs.json";
import da from "@/messages/da.json";
import de from "@/messages/de.json";
import el from "@/messages/el.json";
import en from "@/messages/en.json";
import es from "@/messages/es.json";
import fi from "@/messages/fi.json";
import fr from "@/messages/fr.json";
import hr from "@/messages/hr.json";
import hu from "@/messages/hu.json";
import itMessages from "@/messages/it.json";
import ja from "@/messages/ja.json";
import nl from "@/messages/nl.json";
import no from "@/messages/no.json";
import pl from "@/messages/pl.json";
import pt from "@/messages/pt.json";
import ro from "@/messages/ro.json";
import ru from "@/messages/ru.json";
import sk from "@/messages/sk.json";
import sr from "@/messages/sr.json";
import sv from "@/messages/sv.json";
import tr from "@/messages/tr.json";
import uk from "@/messages/uk.json";
/**
* Every failure reason the register action can report is a `RegisterErrorCode`,
* which the form renders as `pages.register.<code>`. If a code ships without a
* translation the visitor silently sees the raw English fallback, so this test
* locks the contract in both directions: the union of codes must match the
* dictionary, and every locale must carry every key.
*/
type RegisterErrorCode =
| "usernameMinLength"
| "usernameMaxLength"
| "usernamePattern"
| "usernameReserved"
| "usernameTaken"
| "emailValid"
| "emailDisposable"
| "passwordMinLength"
| "passwordMaxLength"
| "passwordUpper"
| "passwordLower"
| "passwordDigit"
| "passwordSpecial"
| "passwordsMatch"
| "termsRequired"
| "captchaFailed"
| "rateLimited"
| "vpnBlocked"
| "maxAccountsPerIp"
| "unavailable"
| "createFailed"
| "invalidInput";
/** Mirrors `RegisterErrorCode` in src/actions/register.ts. */
const REGISTER_ERROR_CODES: readonly RegisterErrorCode[] = [
"usernameMinLength",
"usernameMaxLength",
"usernamePattern",
"usernameReserved",
"usernameTaken",
"emailValid",
"emailDisposable",
"passwordMinLength",
"passwordMaxLength",
"passwordUpper",
"passwordLower",
"passwordDigit",
"passwordSpecial",
"passwordsMatch",
"termsRequired",
"captchaFailed",
"rateLimited",
"vpnBlocked",
"maxAccountsPerIp",
"unavailable",
"createFailed",
"invalidInput",
];
const MESSAGES: Record<string, typeof en> = {
ar,
bg,
cs,
da,
de,
el,
en,
es,
fi,
fr,
hr,
hu,
it: itMessages as unknown as typeof en,
ja,
nl: nl as unknown as typeof en,
no,
pl,
pt,
ro,
ru,
sk,
sr,
sv,
tr,
uk,
};
const locales = readdirSync("src/messages")
.filter((file) => file.endsWith(".json"))
.map((file) => file.replace(/\.json$/, ""))
.sort();
const namespaces = ["pages.register", "pages.login", "pages.reset"] as const;
describe("auth page messages", () => {
it("exposes every register error code as a translated message", () => {
const t = createTranslator({
locale: "en",
messages: en,
namespace: "pages.register",
});
for (const code of REGISTER_ERROR_CODES) {
expect(t.has(code as never), code).toBe(true);
expect(t(code as never), code).not.toBe("");
}
});
it("keeps the dictionary free of unreachable register error keys", () => {
const known = new Set<string>(REGISTER_ERROR_CODES);
for (const key of Object.keys(en.pages.register)) {
// Keys that map a code onto its canonical sentence must stay in sync.
if (key === "passwordMinLength") continue;
expect(known.has(key) || !/^[a-z][A-Z]/.test(key), key).toBe(true);
}
expect(en.pages.register.passwordError).toBe(
en.pages.register.passwordMinLength,
);
});
it("ships a dictionary for every locale on disk", () => {
expect(Object.keys(MESSAGES).sort()).toEqual(locales);
});
it.each(locales)("provides every auth message in %s", (locale) => {
const messages = MESSAGES[locale];
expect(messages, locale).toBeDefined();
for (const namespace of namespaces) {
const t = createTranslator({ locale, messages, namespace });
const keys =
namespace === "pages.register"
? REGISTER_ERROR_CODES
: namespace === "pages.login"
? ([
"username",
"password",
"showPassword",
"hidePassword",
] as const)
: (["passwordMinLength", "tooManyAttempts"] as const);
for (const key of keys) {
expect(t.has(key as never), `${namespace}.${key}`).toBe(true);
expect(t(key as never), `${namespace}.${key}`).not.toBe("");
}
}
});
});