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,310 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import {
|
||||
PACK_AUTO_REFRESH_AFTER_MS,
|
||||
PACK_MAX_AGE_MS,
|
||||
PACK_STALE_MS,
|
||||
deleteAllPacks,
|
||||
findPackForColony,
|
||||
getPack,
|
||||
isPackStale,
|
||||
listPacks,
|
||||
loadPack,
|
||||
packAgeText,
|
||||
purgeOldPacks,
|
||||
refreshPacksAfterSync,
|
||||
} from "./offline-pack";
|
||||
import {
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
MINUTE_MS,
|
||||
offlinePack,
|
||||
packColony,
|
||||
storedPack,
|
||||
} from "./offline-pack.fixtures";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const NOW = Date.parse("2026-10-13T12:00:00.000Z");
|
||||
|
||||
function savedAgo(ms: number): string {
|
||||
return new Date(NOW - ms).toISOString();
|
||||
}
|
||||
|
||||
async function putPacks(...packs: StoredPack[]): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.all(packs.map((pack) => db.put("offline-packs", pack)));
|
||||
}
|
||||
|
||||
async function storedApiaryIds(): Promise<string[]> {
|
||||
const db = await getDb();
|
||||
return (await db.getAllKeys("offline-packs")).sort();
|
||||
}
|
||||
|
||||
describe("offline packs", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stores the pack with owner and time", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
const pack = offlinePack({ apiaryId: "apiary-1" });
|
||||
vi.mocked(apiFetch).mockResolvedValue(pack);
|
||||
|
||||
const stored = await loadPack("apiary-1", "user-1");
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/offline-pack");
|
||||
expect(stored).toEqual({
|
||||
apiaryId: "apiary-1",
|
||||
ownerUserId: "user-1",
|
||||
savedAt: new Date(NOW).toISOString(),
|
||||
pack,
|
||||
});
|
||||
expect(await getPack("apiary-1")).toEqual(stored);
|
||||
});
|
||||
|
||||
it("never stores coordinates or an address, even if the server sent them", async () => {
|
||||
const pack = offlinePack({ apiaryId: "apiary-1" });
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
...pack,
|
||||
apiary: { ...pack.apiary, latitude: 52.1, longitude: 13.4, address: "Feldweg 3", notes: "Schlüssel unterm Stein" },
|
||||
});
|
||||
|
||||
await loadPack("apiary-1", "user-1");
|
||||
|
||||
const kept = await getPack("apiary-1");
|
||||
expect(Object.keys(kept?.pack.apiary ?? {}).sort()).toEqual(["id", "location", "name", "status"]);
|
||||
});
|
||||
|
||||
it("refuses a pack it does not understand instead of storing it", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ...offlinePack(), version: 2 });
|
||||
|
||||
await expect(loadPack("apiary-1", "user-1")).rejects.toThrow();
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("encodes the apiary id in the request", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "a/b" }));
|
||||
|
||||
await loadPack("a/b", "user-1");
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/a%2Fb/offline-pack");
|
||||
});
|
||||
|
||||
it("findPackForColony", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1", colonies: [packColony("colony-1", "A1")] })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("colony-2", "B1")] })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-3", colonies: [packColony("colony-3", "C1")] }), {
|
||||
ownerUserId: "user-2",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await findPackForColony("colony-2", "user-1"))?.apiaryId).toBe("apiary-2");
|
||||
// Another account's pack is never handed out.
|
||||
expect(await findPackForColony("colony-3", "user-1")).toBeNull();
|
||||
expect(await findPackForColony("colony-unknown", "user-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("findPackForColony prefers the newest pack when a colony moved between loaded apiaries", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old", colonies: [packColony("colony-1", "A1")] }), {
|
||||
savedAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
|
||||
}),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-new", colonies: [packColony("colony-1", "A1")] }), {
|
||||
savedAt: new Date(Date.now() - HOUR_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await findPackForColony("colony-1", "user-1"))?.apiaryId).toBe("apiary-new");
|
||||
});
|
||||
|
||||
it("lists only the owner's packs", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
|
||||
expect((await listPacks("user-1")).map((pack) => pack.apiaryId)).toEqual(["apiary-1"]);
|
||||
});
|
||||
|
||||
it("never hands out a pack older than 14 days, even before it is purged", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), {
|
||||
savedAt: new Date(Date.now() - PACK_MAX_AGE_MS - MINUTE_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await listPacks("user-1")).toEqual([]);
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("purges packs older than 14 days", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(15 * DAY_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-edge" }), { savedAt: savedAgo(PACK_MAX_AGE_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(13 * DAY_MS) }),
|
||||
);
|
||||
|
||||
const purged = await purgeOldPacks(NOW);
|
||||
|
||||
expect(purged).toBe(1);
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-edge", "apiary-young"]);
|
||||
});
|
||||
|
||||
it("stale after 24 h", () => {
|
||||
const pack = offlinePack();
|
||||
|
||||
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS - 1) }), NOW)).toBe(false);
|
||||
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS) }), NOW)).toBe(true);
|
||||
expect(isPackStale(storedPack(pack, { savedAt: "kaputt" }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes every pack of every account", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
|
||||
await deleteAllPacks();
|
||||
|
||||
expect(await storedApiaryIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("says how long ago a pack was loaded", () => {
|
||||
const at = (ms: number) => packAgeText(savedAgo(ms), NOW);
|
||||
|
||||
expect(at(20 * 1000)).toBe("vor weniger als einer Minute");
|
||||
expect(at(MINUTE_MS)).toBe("vor 1 Minute");
|
||||
expect(at(5 * MINUTE_MS)).toBe("vor 5 Minuten");
|
||||
expect(at(HOUR_MS)).toBe("vor 1 Stunde");
|
||||
expect(at(3 * HOUR_MS + 10 * MINUTE_MS)).toBe("vor 3 Stunden");
|
||||
expect(at(DAY_MS)).toBe("vor 1 Tag");
|
||||
expect(at(2 * DAY_MS + HOUR_MS)).toBe("vor 2 Tagen");
|
||||
});
|
||||
|
||||
describe("refreshPacksAfterSync", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
it("quietly reloads the owner's packs older than 1 h and leaves younger ones alone", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old" }), {
|
||||
savedAt: savedAgo(PACK_AUTO_REFRESH_AFTER_MS + MINUTE_MS),
|
||||
}),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(30 * MINUTE_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-foreign" }), {
|
||||
ownerUserId: "user-2",
|
||||
savedAt: savedAgo(5 * HOUR_MS),
|
||||
}),
|
||||
);
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-old", apiaryName: "Neu" }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-old/offline-pack");
|
||||
const refreshed = await getPack("apiary-old");
|
||||
expect(refreshed?.pack.apiary.name).toBe("Neu");
|
||||
expect(refreshed?.savedAt).toBe(new Date(NOW).toISOString());
|
||||
});
|
||||
|
||||
it("after a rejection reloads a 10-min-old pack holding the colony, not a 2-min-old one", async () => {
|
||||
await putPacks(
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-ten", colonies: [packColony("colony-3", "V-03")] }),
|
||||
{ savedAt: savedAgo(10 * MINUTE_MS) },
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-two", colonies: [packColony("colony-3", "V-03")] }),
|
||||
{ savedAt: savedAgo(2 * MINUTE_MS) },
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-other", colonies: [packColony("colony-9", "X-09")] }),
|
||||
{ savedAt: savedAgo(10 * MINUTE_MS) },
|
||||
),
|
||||
);
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-ten", colonies: [] }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW, undefined, {
|
||||
force: true,
|
||||
colonyIds: ["colony-3"],
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-ten/offline-pack");
|
||||
// The colony ended on the server: the reloaded pack no longer offers it.
|
||||
expect((await getPack("apiary-ten"))?.pack.colonies).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a pack the user may no longer read or whose apiary is gone", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-gone" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-forbidden" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-broken" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
|
||||
if (path.includes("apiary-gone")) throw new ApiError(404, "Apiary not found");
|
||||
if (path.includes("apiary-forbidden")) throw new ApiError(403, "Forbidden");
|
||||
throw new ApiError(500, "Internal server error");
|
||||
});
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
// A server failure keeps the pack: it is still the best the device knows.
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-broken"]);
|
||||
});
|
||||
|
||||
it("stops at the first network error and never throws", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(refreshPacksAfterSync("user-1", NOW)).resolves.toBeUndefined();
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-1", "apiary-2"]);
|
||||
});
|
||||
|
||||
it("stops once the sync run is cancelled", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
let cancelled = false;
|
||||
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
|
||||
cancelled = true;
|
||||
return offlinePack({ apiaryId: path.split("/")[2] });
|
||||
});
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW, () => cancelled);
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("purges packs older than 14 days on the way", async () => {
|
||||
await putPacks(storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(20 * DAY_MS) }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
expect(await storedApiaryIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user