feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

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 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

@@ -7,6 +7,7 @@ import { readHandOvers, rememberHandOver } from "@/lib/photos/handover";
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
// What the inspection drain hands over to the photos, seen at that moment.
@@ -45,6 +46,9 @@ let photos: PhotoStoreModule;
let ApiError: ApiErrorClass;
let createInspection: MockedFunction<typeof import("@/lib/inspections/api").createInspection>;
let createTreatment: MockedFunction<typeof import("@/lib/treatments/api").createTreatment>;
let createPhenologyReport: MockedFunction<
typeof import("@/lib/phenology/api").createPhenologyReport
>;
let uploadDocument: MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
let refreshPacksAfterSync: MockedFunction<
typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync
@@ -95,6 +99,9 @@ describe("syncPendingEntries", () => {
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
createTreatment = vi.mocked((await import("@/lib/treatments/api")).createTreatment);
uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
createPhenologyReport = vi.mocked(
(await import("@/lib/phenology/api")).createPhenologyReport,
);
refreshPacksAfterSync = vi.mocked(
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
);
@@ -102,6 +109,7 @@ describe("syncPendingEntries", () => {
createInspection.mockReset();
createTreatment.mockReset();
uploadDocument.mockReset();
createPhenologyReport.mockReset();
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
photoHandOver.calls.length = 0;
photoHandOver.failFor = null;
@@ -122,6 +130,55 @@ describe("syncPendingEntries", () => {
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
it("offline the plant report is queued and synced later with its key", async () => {
const report = {
phase: "SALWEIDE",
date: "2026-03-20",
apiaryId: null,
} as const;
await db.queuePhenologyReport(report, "plant-key-0000000001", "user-1");
createPhenologyReport.mockResolvedValue({ id: "r-1" } as never);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(createPhenologyReport).toHaveBeenCalledWith(report, {
idempotencyKey: "plant-key-0000000001",
});
expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([]);
});
it("skips a plant report of another account", async () => {
await db.queuePhenologyReport(
{ phase: "SALWEIDE", date: "2026-03-20" },
"plant-key-0000000004",
"user-2",
);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
expect(createPhenologyReport).not.toHaveBeenCalled();
expect(await db.getPendingEntries("pending-phenology-reports")).toHaveLength(1);
});
it("keeps a plant report the server rejected, with the reason", async () => {
await db.queuePhenologyReport(
{ phase: "KIRSCHE", date: "2026-04-02" },
"plant-key-0000000002",
"user-1",
);
createPhenologyReport.mockRejectedValue(
new ApiError(400, "Das Datum liegt in der Zukunft.", {}),
);
const result = await sync.syncPendingEntries("user-1");
expect(result.failed).toBe(1);
const [entry] = await db.getPendingEntries("pending-phenology-reports");
expect(entry.lastError?.message).toBe("Das Datum liegt in der Zukunft.");
});
it("resends a queued entry with the idempotency key it was queued with", async () => {
// The server may already have saved the first attempt; the same key lets
// it answer with that record instead of storing a duplicate.