Files
HealthLog/src/lib/api/__tests__/localized-error.test.ts
T
Marc-André Bombeck 0639d28f6a fix(i18n): say "sign in again" or "too many requests" on a refused save
A save after the session ran out, or into a rate limit, showed the form's
generic "could not save". The server already names both refusals in
meta.errorCode (auth.missing for an ended web session, the status codes
http.rate_limited, http.forbidden and http.invalid otherwise), but the
catalogs had no sentence for them, so the lookup fell through to the
fallback. They have one now in all ten locales, and a refusal that
reaches the client without a code (a reverse proxy's 429) is read by its
status the way the server labels it.
2026-11-11 19:48:27 +01:00

164 lines
4.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { ApiError } from "../api-fetch";
import { localizedApiError } from "../localized-error";
import de from "../../../../messages/de.json";
import en from "../../../../messages/en.json";
import es from "../../../../messages/es.json";
import fr from "../../../../messages/fr.json";
import itCatalog from "../../../../messages/it.json";
import pl from "../../../../messages/pl.json";
type Catalog = Record<string, unknown>;
function translator(catalog: Catalog) {
return (key: string): string => {
const value = key.split(".").reduce<unknown>((node, part) => {
if (!node || typeof node !== "object") return undefined;
return (node as Record<string, unknown>)[part];
}, catalog);
return typeof value === "string" ? value : key;
};
}
const catalog = {
measurements: { saveError: "Generic measurement failure" },
mood: { saveError: "Generic mood failure" },
apiErrors: {
measurement: {
create: { invalid: "Localized invalid measurement" },
duplicate_timestamp: "Localized duplicate measurement",
},
mood: {
create: { invalid: "Localized invalid mood" },
not_found: "Localized missing mood",
},
},
};
describe("localizedApiError", () => {
it("maps a stable hierarchical error code to localized copy", () => {
const error = new ApiError("Validation failed", 422, {
errorCode: "measurement.create.invalid",
});
expect(
localizedApiError(error, translator(catalog), "measurements.saveError"),
).toBe("Localized invalid measurement");
});
it("never exposes the English server message for a handled code", () => {
const error = new ApiError(
"A mood entry with this data already exists",
409,
{ errorCode: "mood.not_found" },
);
expect(
localizedApiError(error, translator(catalog), "mood.saveError"),
).toBe("Localized missing mood");
});
it.each([
new ApiError("raw database failure", 500),
new ApiError("future server prose", 409, {
errorCode: "mood.future.unknown",
}),
new TypeError("network details"),
])("uses the safe generic fallback for %s", (error) => {
expect(
localizedApiError(error, translator(catalog), "mood.saveError"),
).toBe("Generic mood failure");
});
it.each([
["de", de],
["en", en],
["es", es],
["fr", fr],
["it", itCatalog],
["pl", pl],
])(
"resolves canonical codes in %s without exposing server prose",
(_, locale) => {
const error = new ApiError("raw English server prose", 422, {
errorCode: "measurement.create.invalid",
});
const message = localizedApiError(
error,
translator(locale as Catalog),
"measurements.saveError",
);
expect(message).not.toBe("raw English server prose");
expect(message).not.toBe("apiErrors.measurement.create.invalid");
},
);
it.each([
["de", de],
["en", en],
["es", es],
["fr", fr],
["it", itCatalog],
["pl", pl],
])("falls back safely in %s when no localized code exists", (_, locale) => {
const error = new ApiError("raw English server prose", 409, {
errorCode: "future.unknown",
});
expect(
localizedApiError(
error,
translator(locale as Catalog),
"measurements.saveError",
),
).toBe(translator(locale as Catalog)("measurements.saveError"));
});
describe("a refusal the client can act on", () => {
const enT = translator(en as Catalog);
it.each([
["an ended session", 401, "auth.missing", "Sign in again"],
["a rate limit", 429, "http.rate_limited", "too many requests"],
["a missing right", 403, "http.forbidden", "permission"],
["a rejected entry", 422, "http.invalid", "Check them"],
])("names %s instead of the save fallback", (_, status, code, text) => {
const error = new ApiError("raw English server prose", status, {
errorCode: code,
});
expect(localizedApiError(error, enT, "measurements.saveError")).toMatch(
new RegExp(text, "i"),
);
});
it("reads a refusal without a code by its status", () => {
// A reverse proxy's 429 carries no envelope at all.
const error = new ApiError("Request failed (429)", 429);
expect(localizedApiError(error, enT, "measurements.saveError")).toBe(
enT("apiErrors.http.rate_limited"),
);
});
it.each([
["de", de],
["es", es],
["fr", fr],
["it", itCatalog],
["pl", pl],
])("has its own sentence in %s", (_, locale) => {
const localT = translator(locale as Catalog);
for (const code of [
"auth.missing",
"http.unauthenticated",
"http.rate_limited",
"http.forbidden",
"http.invalid",
]) {
const sentence = localT(`apiErrors.${code}`);
expect(sentence).not.toBe(`apiErrors.${code}`);
expect(sentence).not.toBe(enT(`apiErrors.${code}`));
}
});
});
});