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
`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`.
175 lines
4.5 KiB
TypeScript
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("");
|
|
}
|
|
}
|
|
});
|
|
});
|