feat(i18n): every server refusal is said by a record the contract holds

A refusal code or reason with no sentence fell through to the generic
"couldn't carry out that action" unnoticed: the parity test is green when
EN and HU miss the same key. One did (an edit refused for a file that is
not video or audio).

- lib/api/refusalCodes.ts: which sentence answers each code, as data;
  localizeDetail says a refusal through it, unchanged for every reader.
- backendIds contract: every detail code the backend sends is in exactly
  one record, every reason it can carry has its sentence in EN and HU,
  and no family names a reason the backend cannot send.
- The reasons that travel in a variable are Literal types (edit, move,
  cookies, quota), so the type checker refuses an unknown one too.
- downloads.errors.edit.not_media in EN and HU.
This commit is contained in:
2026-10-30 23:36:25 +01:00
parent b3be568e36
commit 4379f76747
14 changed files with 381 additions and 123 deletions
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
//
// A server refusal said through the records (`refusalCodes`), in both languages, by the real i18n —
// one case per record shape, so moving the dispatch into data changed what no reader sees.
import { afterEach, describe, expect, it } from "vitest";
import i18n from "../../i18n";
import { localizeDetail } from "./refusalText";
afterEach(async () => {
await i18n.changeLanguage("en");
});
describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
const say = async (d: unknown) => {
await i18n.changeLanguage(lng);
return localizeDetail(d);
};
it("names an edit refused for a file that is not video or audio", async () => {
const said = await say({ code: "edit", reason: "not_media" });
expect(said).toBe(i18n.t("downloads.errors.edit.not_media"));
expect(said).not.toBe(i18n.t("downloads.errors.edit.generic"));
});
it("says a family's reason, and its own fallback for a reason it does not know", async () => {
expect(await say({ code: "playlist_move", reason: "same_playlist" })).toBe(
i18n.t("playlists.moveErrors.same_playlist"),
);
expect(await say({ code: "playlist_move", reason: "nope" })).toBe(
i18n.t("playlists.moveFailed"),
);
});
it("reads a sign-in refusal's one-line form", async () => {
expect(await say({ code: "login", reason: "suspended" })).toBe(
i18n.t("welcome.auth.blocked.suspended.short"),
);
});
it("puts a refusal's numbers into its sentence", async () => {
expect(await say({ code: "quota", reason: "max_jobs", limit: 7 })).toContain("7");
expect(await say({ code: "reset", reason: "weak_password", min: 12 })).toContain("12");
expect(await say({ code: "refused", reason: "view_name_taken", name: "Esti" })).toContain(
"Esti",
);
});
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
expect(await say({ code: "reset", reason: "invalid" })).toBe(
i18n.t("welcome.auth.deadLink.short"),
);
expect(await say({ code: "sync_cooldown" })).toBe(i18n.t("errors.syncCooldown"));
});
it("reads a code no screen shows, or one it does not know, as the generic failure", async () => {
expect(await say({ code: "bad_cursor" })).toBe(i18n.t("errors.generic"));
expect(await say({ code: "never_sent" })).toBe(i18n.t("errors.generic"));
expect(await say({ code: "constructor" })).toBe(i18n.t("errors.generic"));
expect(await say(null)).toBe(i18n.t("errors.generic"));
});
});