mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
The visit, vaccination, allergy, address-book and workout forms read a refusal's code through the catalogue, but nineteen of the codes their routes send had no sentence: a stale doctor or check-up, a planned visit that would close a check-up, a dose without a name, a booster that cannot be planned, an invalid body, a workout batch that is too large. Each of them read as the form's generic "could not be saved", which hides what the person can change. They now have their own sentence in all ten languages.
218 lines
6.7 KiB
TypeScript
218 lines
6.7 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 ko from "../../../../messages/ko.json";
|
|
import nl from "../../../../messages/nl.json";
|
|
import pl from "../../../../messages/pl.json";
|
|
import ptBR from "../../../../messages/pt-BR.json";
|
|
import ru from "../../../../messages/ru.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}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the refusals of the visit, vaccination, allergy, address-book and workout saves", () => {
|
|
// Each of these is a code the route sends to a form that reads it through
|
|
// `localizedApiError`; without a sentence the form says only "could not
|
|
// save", which hides a fix the person can make.
|
|
const codes = [
|
|
"encounter.invalid",
|
|
"encounter.practitioner-not-found",
|
|
"encounter.reminder-not-found",
|
|
"encounter.reminder-conflict",
|
|
"vaccination.invalid",
|
|
"vaccination.not-found",
|
|
"vaccination.identity-required",
|
|
"vaccination.practitioner-not-found",
|
|
"vaccination.encounter-not-found",
|
|
"vaccination.custom-vaccine-not-found",
|
|
"vaccination.booster-invalid",
|
|
"vaccination.booster-no-antigen",
|
|
"vaccination.booster-out-of-scope",
|
|
"allergy.invalid",
|
|
"practitioner.invalid",
|
|
"workout.batch.invalid",
|
|
"workout.batch.too_large",
|
|
"workout.batch.payload_too_large",
|
|
"workout.batch.source_not_permitted",
|
|
];
|
|
|
|
it.each([
|
|
["de", de],
|
|
["en", en],
|
|
["es", es],
|
|
["fr", fr],
|
|
["it", itCatalog],
|
|
["ko", ko],
|
|
["nl", nl],
|
|
["pl", pl],
|
|
["pt-BR", ptBR],
|
|
["ru", ru],
|
|
])("has a sentence of its own for each in %s", (_, locale) => {
|
|
const localT = translator(locale as Catalog);
|
|
for (const code of codes) {
|
|
const error = new ApiError("raw English server prose", 422, {
|
|
errorCode: code,
|
|
});
|
|
expect(localizedApiError(error, localT, "common.error"), code).not.toBe(
|
|
localT("common.error"),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|