Files
ApiCore/ApiCore-Frontend/src/lib/bulk/api.spec.ts
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
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]>
2026-10-14 11:36:38 +02:00

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);
});
});