Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
|
import {
|
|
BULK_MAX_COLONIES,
|
|
createFeedingsBulk,
|
|
createTreatmentsBulk,
|
|
createVarroaBulk,
|
|
invalidColonyIdsOf,
|
|
RETRY_LATER_MESSAGES,
|
|
isRetryLaterError,
|
|
} from "./api";
|
|
|
|
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
|
apiFetch: vi.fn(),
|
|
}));
|
|
|
|
const KEY = "0123456789abcdef-key";
|
|
|
|
describe("bulk api", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(apiFetch).mockReset().mockResolvedValue({ bulkId: "b-1", records: [] } as never);
|
|
});
|
|
|
|
it("allows at most 100 colonies, like the server", () => {
|
|
expect(BULK_MAX_COLONIES).toBe(100);
|
|
});
|
|
|
|
it("sends the Idempotency-Key", async () => {
|
|
const input = { date: "2026-10-14", product: "Oxuvar", colonyIds: ["a", "b"] };
|
|
|
|
await createTreatmentsBulk(input, { idempotencyKey: KEY });
|
|
|
|
expect(apiFetch).toHaveBeenCalledWith("/treatments/bulk", {
|
|
method: "POST",
|
|
headers: { "Idempotency-Key": KEY },
|
|
body: JSON.stringify(input),
|
|
});
|
|
});
|
|
|
|
it("posts feedings and varroa counts to their own bulk endpoints", async () => {
|
|
await createFeedingsBulk(
|
|
{ date: "2026-10-14", feedType: "SUGAR_WATER", amount: 2, unit: "l", colonyIds: ["a"] },
|
|
{ idempotencyKey: KEY },
|
|
);
|
|
await createVarroaBulk(
|
|
{
|
|
date: "2026-10-14",
|
|
method: "NATURAL_MITE_FALL",
|
|
durationMinutes: 4320,
|
|
entries: [{ colonyId: "a", miteCount: 9 }],
|
|
},
|
|
{ idempotencyKey: KEY },
|
|
);
|
|
|
|
expect(vi.mocked(apiFetch).mock.calls.map(([path]) => path)).toEqual([
|
|
"/feedings/bulk",
|
|
"/varroa-measurements/bulk",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("invalidColonyIdsOf", () => {
|
|
it("reads ApiError.body", () => {
|
|
const error = new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
|
|
message: "Einige Völker sind nicht (mehr) erfassbar",
|
|
invalidColonyIds: ["c-3", "c-5"],
|
|
});
|
|
|
|
expect(invalidColonyIdsOf(error)).toEqual(["c-3", "c-5"]);
|
|
});
|
|
|
|
it("is empty for anything else", () => {
|
|
expect(invalidColonyIdsOf(new ApiError(400, "x", {}))).toEqual([]);
|
|
expect(invalidColonyIdsOf(new ApiError(400, "x", { invalidColonyIds: "c-3" }))).toEqual([]);
|
|
expect(invalidColonyIdsOf(new ApiError(400, "x", { invalidColonyIds: ["c-3", 7] }))).toEqual([
|
|
"c-3",
|
|
]);
|
|
expect(invalidColonyIdsOf(new Error("x"))).toEqual([]);
|
|
expect(invalidColonyIdsOf(undefined)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("isRetryLaterError", () => {
|
|
it("is true for „wird bereits verarbeitet“ and a just-released key, not for other 409s", () => {
|
|
expect(
|
|
isRetryLaterError(
|
|
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isRetryLaterError(
|
|
new ApiError(409, "Diese Anfrage wurde gerade abgebrochen - bitte erneut senden"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isRetryLaterError(
|
|
new ApiError(409, "Dieser Idempotency-Key wurde bereits für eine andere Aktion verwendet"),
|
|
),
|
|
).toBe(false);
|
|
expect(isRetryLaterError(new ApiError(400, "wird bereits verarbeitet"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Relative to this file, not the working directory. The backend is absent in
|
|
// the frontend-only Docker build; the pin below then runs in the full
|
|
// monorepo checks only.
|
|
const BACKEND_INTERCEPTOR = resolve(
|
|
__dirname,
|
|
"../../../../ApiCore-Backend/src/common/idempotency/idempotency.interceptor.ts",
|
|
);
|
|
const hasBackend = existsSync(BACKEND_INTERCEPTOR);
|
|
|
|
describe("retry-later messages", () => {
|
|
// The server has no error code for these two 409s, so the client matches
|
|
// the German text. The cross-check pins it to the backend source: a reworded
|
|
// message there fails here instead of silently turning "later" into "rejected".
|
|
it.each(RETRY_LATER_MESSAGES)("is recognised as retry-later: %s", (message) => {
|
|
expect(isRetryLaterError(new ApiError(409, message))).toBe(true);
|
|
});
|
|
|
|
it.skipIf(!hasBackend).each(RETRY_LATER_MESSAGES)("the backend still says: %s", (message) => {
|
|
expect(readFileSync(BACKEND_INTERCEPTOR, "utf-8")).toContain(message);
|
|
});
|
|
|
|
it("the key-used-for-another-action message is not among them", () => {
|
|
expect(RETRY_LATER_MESSAGES.some((message) => message.includes("andere Aktion"))).toBe(false);
|
|
});
|
|
});
|