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:
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.
|
||||
|
||||
Reference in new issue
Block a user