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