fix(errors): name three refusals that fell back to a generic sentence

Since a codeless refusal reads by its status, three answers lost their
meaning. A failed ChatGPT sign-in poll said "Error saving" instead of
asking to start the sign-in again; it now falls back to the sign-in's
own failure sentence. A dose-history import still running when the
card stopped watching said "Nothing was written" although the job may
still write; that case now has its own sentence. Setting up an
authenticator while one is active answered "setup failed"; the route
now sends `auth.mfa.already_active`, which says to disable the active
one first, in every locale.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 12:58:52 +01:00
1 parent 31c4440154
commit 8c9fbbdd2b
17 files changed
+116 -11

No files matched your search

@@ -126,6 +126,21 @@ describe("localizedApiError", () => {
["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"],
["a mistyped proof", 401, "auth.reproof.failed", "did not verify"],
[
"a password where a second factor is needed",
422,
"auth.reproof.too_weak",
"second factor",
],
[
"an authenticator already set up",
409,
"auth.mfa.already_active",
"Disable it first",
],
["nothing to test with", 422, "ai.provider.none", "No AI provider"],
["a spent AI budget", 429, "ai.budget.exceeded", "budget is used up"],
])("names %s instead of the save fallback", (_, status, code, text) => {
const error = new ApiError("raw English server prose", status, {
errorCode: code,
@@ -157,6 +172,10 @@ describe("localizedApiError", () => {
"http.rate_limited",
"http.forbidden",
"http.invalid",
"auth.reproof.too_weak",
"auth.mfa.already_active",
"ai.provider.none",
"ai.budget.exceeded",
]) {
const sentence = localT(`apiErrors.${code}`);
expect(sentence).not.toBe(`apiErrors.${code}`);
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { waitForMedicationIntakeImport } from "../intake-import-poll";
import {
MedicationIntakeImportTimeout,
waitForMedicationIntakeImport,
} from "../intake-import-poll";
describe("waitForMedicationIntakeImport", () => {
it("polls queued and running jobs until the durable result is done", async () => {
@@ -53,4 +56,19 @@ describe("waitForMedicationIntakeImport", () => {
expect(readStatus).toHaveBeenCalledOnce();
expect(wait).not.toHaveBeenCalled();
});
it("tells a watch that gave up from a job that failed", async () => {
// The job may still write, so the card must not say nothing was written.
const readStatus = vi.fn().mockResolvedValue({
status: "running",
result: null,
});
await expect(
waitForMedicationIntakeImport("/api/import/job-1/status", {
readStatus,
wait: vi.fn().mockResolvedValue(undefined),
maxAttempts: 2,
}),
).rejects.toBeInstanceOf(MedicationIntakeImportTimeout);
});
});
+9 -1
View File
@@ -29,6 +29,14 @@ export class MedicationIntakeImportError extends Error {
}
}
/** The job did not settle while the client watched; it may still write. */
export class MedicationIntakeImportTimeout extends MedicationIntakeImportError {
constructor() {
super("Medication intake import timed out");
this.name = "MedicationIntakeImportTimeout";
}
}
const waitForNextPoll = () =>
new Promise<void>((resolve) => setTimeout(resolve, 1_000));
@@ -52,5 +60,5 @@ export async function waitForMedicationIntakeImport(
if (attempt + 1 < maxAttempts) await wait();
}
throw new MedicationIntakeImportError("Medication intake import timed out");
throw new MedicationIntakeImportTimeout();
}
+1
View File
@@ -86,6 +86,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.account.deletion_pending",
"auth.admin.required",
"auth.already_authenticated",
"auth.mfa.already_active",
"auth.mfa.code_invalid",
"auth.missing",
"auth.passkey.last_method",