fix: T6 browser-check findings (compliance, treatments, labels)
- api-client accepts 2xx answers without body (revoke showed an error) - authority notices: sentOn in the future is rejected (Berlin day) on create, mark-sent and PATCH; dialog defaults to the Berlin day - service worker never caches /api/v1/site-settings - treatments/new no longer overflows at 390 px (min-w-0 on form wrappers); dose with more than 3 decimals is an error, never rounded - medicines: expired lots flagged, disposal over the rest warns - label designer: side panel at lg, sample values instead of placeholders, compliance panel reopens on lg, labelled zoom buttons - skip link to #main-content, headings on no-access pages Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6c390efe7b
commit
e712c5d4d1
64 files changed
+1497
-323
No files matched your search
@@ -44,6 +44,33 @@ describe("api-client", () => {
|
||||
expect((apiError.body as { invalidColonyIds: string[] }).invalidColonyIds).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
describe("empty success bodies", () => {
|
||||
it.each([200, 201, 202])("resolves undefined for a %i with an empty body", async (status) => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status })));
|
||||
await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves undefined for a 204", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
|
||||
await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves undefined when Content-Length is 0", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response("", { status: 201, headers: { "Content-Length": "0" } })),
|
||||
);
|
||||
await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("still parses a JSON body and still rejects invalid JSON", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(201, { id: "a" })));
|
||||
await expect(apiFetch("/x", { method: "POST" })).resolves.toEqual({ id: "a" });
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("<html>", { status: 200 })));
|
||||
await expect(apiFetch("/x")).rejects.toBeInstanceOf(SyntaxError);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports network-error on a failed fetch and reached-server on any HTTP answer", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
await expect(apiFetch("/x")).rejects.toBeInstanceOf(TypeError);
|
||||
|
||||
@@ -159,9 +159,23 @@ export async function apiFetch<T>(
|
||||
throw await apiErrorFrom(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return parseSuccessBody<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 204, 205 and any 2xx with an empty body (e.g. a 201 from a void controller)
|
||||
* yield undefined; everything else is parsed as JSON.
|
||||
*/
|
||||
async function parseSuccessBody<T>(response: Response): Promise<T> {
|
||||
if (response.status === 204 || response.status === 205) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
if (response.headers.get('Content-Length') === '0') {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (text.trim() === '') {
|
||||
return undefined as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { overlappingRestrictions } from "./restriction-overlap";
|
||||
import type { ApiaryRestriction } from "./restrictions-api";
|
||||
|
||||
function row(overrides: Partial<ApiaryRestriction>): ApiaryRestriction {
|
||||
return {
|
||||
id: "r1",
|
||||
apiaryId: "a1",
|
||||
disease: "Amerikanische Faulbrut",
|
||||
startsOn: "2026-10-01T00:00:00.000Z",
|
||||
endsOn: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("overlappingRestrictions", () => {
|
||||
it("finds an open-ended restriction at the same apiary", () => {
|
||||
expect(overlappingRestrictions([row({})], ["a1"], { startsOn: "2026-12-01" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores other apiaries", () => {
|
||||
expect(overlappingRestrictions([row({ apiaryId: "a2" })], ["a1"], { startsOn: "2026-12-01" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a restriction that ended before the new start, but counts the end day itself", () => {
|
||||
const ended = row({ endsOn: "2026-11-30T00:00:00.000Z" });
|
||||
expect(overlappingRestrictions([ended], ["a1"], { startsOn: "2026-12-01" })).toEqual([]);
|
||||
expect(overlappingRestrictions([ended], ["a1"], { startsOn: "2026-11-30" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores a restriction that starts after the new end", () => {
|
||||
const later = row({ startsOn: "2027-01-01T00:00:00.000Z" });
|
||||
expect(overlappingRestrictions([later], ["a1"], { startsOn: "2026-12-01", endsOn: "2026-12-31" })).toEqual([]);
|
||||
expect(overlappingRestrictions([later], ["a1"], { startsOn: "2026-12-01", endsOn: "2027-01-01" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns nothing without a start day", () => {
|
||||
expect(overlappingRestrictions([row({})], ["a1"], { startsOn: "" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { toCalendarDay } from "@/lib/dates";
|
||||
import type { ApiaryRestriction } from "./restrictions-api";
|
||||
|
||||
interface NewPeriod {
|
||||
/** Calendar day "YYYY-MM-DD". */
|
||||
startsOn: string;
|
||||
/** Calendar day; empty or undefined = until further notice. */
|
||||
endsOn?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The existing restrictions at the chosen apiaries whose period overlaps the
|
||||
* new one (days are inclusive, no end day = open-ended). Only a hint: the
|
||||
* server accepts overlapping restrictions.
|
||||
*/
|
||||
export function overlappingRestrictions(
|
||||
existing: readonly ApiaryRestriction[],
|
||||
apiaryIds: readonly string[],
|
||||
period: NewPeriod,
|
||||
): ApiaryRestriction[] {
|
||||
if (!period.startsOn) return [];
|
||||
const wanted = new Set(apiaryIds);
|
||||
const newEnd = period.endsOn ? period.endsOn : null;
|
||||
return existing.filter((row) => {
|
||||
if (!wanted.has(row.apiaryId)) return false;
|
||||
const rowEnd = row.endsOn === null ? null : toCalendarDay(row.endsOn);
|
||||
const startsBeforeRowEnds = rowEnd === null || period.startsOn <= rowEnd;
|
||||
const rowStartsBeforeNewEnds = newEnd === null || toCalendarDay(row.startsOn) <= newEnd;
|
||||
return startsBeforeRowEnds && rowStartsBeforeNewEnds;
|
||||
});
|
||||
}
|
||||
@@ -25,3 +25,6 @@ export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; is
|
||||
});
|
||||
return { restrictions: query.data ?? NONE, isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
/** The full restriction list (all states) of the restrictions page. */
|
||||
export const restrictionListKey = [...complianceKey, "restrictions", "list"] as const;
|
||||
@@ -35,3 +35,23 @@ export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
|
||||
bestBeforeLine: "Zeile „Mindestens haltbar bis …“",
|
||||
lotLine: "Zeile „L Losnummer“",
|
||||
};
|
||||
|
||||
/**
|
||||
* What the designer preview shows for a placeholder instead of the raw
|
||||
* `{{key}}`: a plausible value, so sizes and line breaks look like the print.
|
||||
*/
|
||||
export const PLACEHOLDER_SAMPLES: Record<PlaceholderKey, string> = {
|
||||
batchNumber: "WH-2026-0001",
|
||||
honeyType: "Blütenhonig",
|
||||
lotNumber: "L-0001",
|
||||
bottlingDate: "15.10.2026",
|
||||
bestBeforeDate: "10/2028",
|
||||
jarSize: "500 g",
|
||||
productName: "Wabenhain Blütenhonig",
|
||||
legalName: "Blütenhonig",
|
||||
originCountry: "Herkunft: Deutschland",
|
||||
foodBusinessName: "Max Mustermann",
|
||||
foodBusinessAddress: "Musterstr. 1\n86450 Altenmünster",
|
||||
bestBeforeLine: "Mindestens haltbar bis Ende 10/2028",
|
||||
lotLine: "L 0001",
|
||||
};
|
||||
@@ -247,6 +247,9 @@ describe("service worker routes", () => {
|
||||
"/api/v1/label-templates/t-1/print/b-1",
|
||||
"/api/v1/label-templates/t-1/print",
|
||||
"/api/v1/compliance/readiness?x=1",
|
||||
"/api/v1/site-settings",
|
||||
"/api/v1/site-settings/",
|
||||
"/api/v1/Site-Settings/public",
|
||||
])("never caches %s", (path) => {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
});
|
||||
@@ -271,6 +274,17 @@ describe("service worker routes", () => {
|
||||
expect(cachesApi("/api/v1/documents/d-1/download")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps caching the photo document lists and the bottling and treatment answers offline", () => {
|
||||
for (const path of [
|
||||
"/api/v1/documents?entityType=INSPECTION&entityId=i-1",
|
||||
"/api/v1/bottling-batches",
|
||||
"/api/v1/colonies/c-1/treatments",
|
||||
"/api/v1/treatments/withdrawal-status",
|
||||
]) {
|
||||
expect(cachesApi(path)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps caching the unrelated routes it cached before", () => {
|
||||
for (const path of [
|
||||
"/api/v1/tasks",
|
||||
@@ -280,6 +294,7 @@ describe("service worker routes", () => {
|
||||
"/api/v1/label-templates/t-1",
|
||||
"/api/v1/compliances",
|
||||
"/api/v1/veterinary-medicines-overview",
|
||||
"/api/v1/site-settings-overview",
|
||||
"/api/v1/apiaries/a-1/weather",
|
||||
]) {
|
||||
expect(cachesApi(path)).toBe(true);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { doseError, doseText, parsedDose } from "./dose";
|
||||
import { doseError, doseFormatError, doseText, parsedDose } from "./dose";
|
||||
|
||||
describe("doseError", () => {
|
||||
it("allows an empty dose and valid ones with up to three decimals", () => {
|
||||
@@ -11,6 +11,12 @@ describe("doseError", () => {
|
||||
it("rejects more than three decimal places in German", () => {
|
||||
expect(doseError("0,0004")).toBe("Höchstens 3 Nachkommastellen.");
|
||||
expect(doseError("2,5001")).toBe("Höchstens 3 Nachkommastellen.");
|
||||
expect(doseError("1,2345")).toBe("Höchstens 3 Nachkommastellen.");
|
||||
expect(doseError("1.2345")).toBe("Höchstens 3 Nachkommastellen.");
|
||||
});
|
||||
|
||||
it("names the thousands dot", () => {
|
||||
expect(doseError("1.000")).toMatch(/Tausenderpunkt/);
|
||||
});
|
||||
|
||||
it("rejects zero and text", () => {
|
||||
@@ -19,7 +25,21 @@ describe("doseError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("doseFormatError", () => {
|
||||
it("reports only the way the dose is written, so it can show while typing", () => {
|
||||
expect(doseFormatError("1,2345")).toBe("Höchstens 3 Nachkommastellen.");
|
||||
expect(doseFormatError("1.000")).toMatch(/Tausenderpunkt/);
|
||||
expect(doseFormatError("0")).toBeNull();
|
||||
expect(doseFormatError("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsedDose / doseText", () => {
|
||||
it("never rounds a dose with more than three decimals (single and bulk booking use it)", () => {
|
||||
expect(parsedDose("1,2345")).toBeNull();
|
||||
expect(parsedDose("1,234")).toBe(1.234);
|
||||
});
|
||||
|
||||
it("round-trips a German decimal", () => {
|
||||
expect(parsedDose(doseText(2.5))).toBe(2.5);
|
||||
expect(doseText(null)).toBe("");
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
|
||||
import {
|
||||
decimalMessage,
|
||||
isAmbiguousDecimal,
|
||||
parseDecimal,
|
||||
THOUSANDS_SEPARATOR_MESSAGE,
|
||||
} from "@/lib/medicines/format";
|
||||
|
||||
/** The smallest dose the server accepts. */
|
||||
export const MIN_DOSE = 0.001;
|
||||
const MAX_DECIMALS = 3;
|
||||
const DECIMALS = /[.,](\d+)$/;
|
||||
|
||||
function decimalPlaces(text: string): number {
|
||||
return DECIMALS.exec(text.trim())?.[1].length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is wrong with the way the dose is written (thousands dot, too many
|
||||
* decimals), or `null`. Cheap and final enough to show while typing.
|
||||
*/
|
||||
export function doseFormatError(text: string): string | null {
|
||||
if (text.trim() === "") return null;
|
||||
if (isAmbiguousDecimal(text)) return THOUSANDS_SEPARATOR_MESSAGE;
|
||||
if (decimalPlaces(text) > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What is wrong with the typed dose, or `null` (an empty dose is allowed: nothing is booked). */
|
||||
export function doseError(text: string): string | null {
|
||||
if (text.trim() === "") return null;
|
||||
const formatError = doseFormatError(text);
|
||||
if (formatError) return formatError;
|
||||
const dose = parseDecimal(text);
|
||||
if (Number.isNaN(dose)) return decimalMessage(text, "Bitte die Dosis als Zahl angeben, z. B. 2,5.");
|
||||
const decimals = DECIMALS.exec(text.trim())?.[1].length ?? 0;
|
||||
if (decimals > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen.";
|
||||
if (!(dose >= MIN_DOSE)) return "Die Dosis muss mindestens 0,001 sein.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The typed dose as a number, `null` when empty or not a valid number. */
|
||||
/**
|
||||
* The typed dose as a number, `null` when empty or not a valid dose. A dose
|
||||
* with more than three decimals is never rounded or cut here: it is `null`,
|
||||
* so no booking line, payload or patch is ever built from it.
|
||||
*/
|
||||
export function parsedDose(text: string): number | null {
|
||||
if (text.trim() === "") return null;
|
||||
if (decimalPlaces(text) > MAX_DECIMALS) return null;
|
||||
const dose = parseDecimal(text);
|
||||
return Number.isFinite(dose) ? dose : null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user