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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

@@ -7,7 +7,7 @@ import {
UPLOAD_TIMEOUT_MS,
} from "@/lib/http/fetch-with-timeout";
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
import { setAccessToken } from "./token-store";
import { getAccessToken, setAccessToken, setRefreshedTokenHandler } from "./token-store";
import { ApiError, apiFetch, apiFetchBlob, apiFetchFormData } from "./api-client";
vi.mock("../config/runtime-config", () => ({
@@ -102,3 +102,67 @@ describe("api-client", () => {
expect(error.body).toEqual({ message: "Nein", why: 1 });
});
});
describe("api-client token refresh", () => {
beforeEach(() => {
setAccessToken(null);
reportRequestOutcome("reached-server");
});
afterEach(() => {
setRefreshedTokenHandler(null);
vi.unstubAllGlobals();
});
function stubServer(refreshBody: unknown) {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("/auth/refresh")) return jsonResponse(200, refreshBody);
const retried = fetchMock.mock.calls.filter(([called]) => called === url).length > 1;
return retried ? jsonResponse(200, { ok: true }) : jsonResponse(401, {});
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
it("retries with the new token once the handler accepts it", async () => {
const handler = vi.fn().mockResolvedValue(true);
setRefreshedTokenHandler(handler);
stubServer({ accessToken: "fresh" });
await expect(apiFetch("/things")).resolves.toEqual({ ok: true });
expect(handler).toHaveBeenCalledWith("fresh");
});
it("does not retry - and keeps no token - when the handler rejects it (another account)", async () => {
setRefreshedTokenHandler(vi.fn().mockResolvedValue(false));
const fetchMock = stubServer({ accessToken: "other-users-token" });
await expect(apiFetch("/things")).rejects.toMatchObject({ status: 401 });
expect(getAccessToken()).toBeNull();
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/things"))).toHaveLength(1);
});
it("takes the token as it is without a handler", async () => {
stubServer({ accessToken: "fresh" });
await expect(apiFetch("/things")).resolves.toEqual({ ok: true });
expect(getAccessToken()).toBe("fresh");
});
it.each([
["blobs", () => apiFetchBlob("/export", "x.csv")],
["form data", () => apiFetchFormData("/upload", new FormData())],
])("also asks the handler before retrying %s", async (_name, call) => {
const handler = vi.fn().mockResolvedValue(false);
setRefreshedTokenHandler(handler);
stubServer({ accessToken: "other" });
await expect(call()).rejects.toBeInstanceOf(ApiError);
expect(handler).toHaveBeenCalledWith("other");
expect(getAccessToken()).toBeNull();
});
});