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]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,131 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user