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,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { fetchColonyOverview } from "./field-api";
|
||||
import { listPacks } from "./offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "./offline-pack.fixtures";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock("./offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
const PACK = offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
colonies: [packColony("colony-1", "A1"), packColony("colony-2", "A2")],
|
||||
});
|
||||
|
||||
describe("fetchColonyOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([storedPack(PACK)]);
|
||||
});
|
||||
|
||||
it("asks the server", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(PACK.overview);
|
||||
|
||||
expect(await fetchColonyOverview("apiary-1", "user-1")).toEqual(PACK.overview);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/colony-overview");
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers from the loaded pack without network", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
const overview = await fetchColonyOverview("apiary-1", "user-1");
|
||||
|
||||
expect(overview.rows.map((row) => row.code)).toEqual(["A1", "A2"]);
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("fails without network when this apiary was not loaded", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-2", "user-1")).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
it("never replaces a refusal with pack data", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "Apiary not found"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-1", "user-1")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only asks the server when nobody is signed in", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-1", null)).rejects.toBeInstanceOf(TypeError);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,26 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
import type { ColonyOverview } from "./field-types";
|
||||
|
||||
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview> {
|
||||
return apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
/**
|
||||
* The apiary's colonies with their traffic-light inputs. For a signed-in
|
||||
* user without network it answers from the apiary's offline pack, if loaded
|
||||
* (same shape: the pack carries the overview as it was when loaded).
|
||||
*/
|
||||
export function fetchColonyOverview(
|
||||
apiaryId: string,
|
||||
ownerUserId: string | null = null,
|
||||
): Promise<ColonyOverview> {
|
||||
const fromServer = () =>
|
||||
apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
if (!ownerUserId) {
|
||||
return fromServer();
|
||||
}
|
||||
return withPackFallback(
|
||||
fromServer,
|
||||
(packs) => packs.find((stored) => stored.apiaryId === apiaryId)?.pack.overview ?? null,
|
||||
ownerUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Query key of the apiary page's colony overview. */
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Test data for offline packs - shared by the specs, never imported by app code.
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
import type { ColonyOverviewRow, OfflinePackV1 } from "./field-types";
|
||||
|
||||
export const MINUTE_MS = 60 * 1000;
|
||||
export const HOUR_MS = 60 * MINUTE_MS;
|
||||
export const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
type PackColony = OfflinePackV1["colonies"][number];
|
||||
type PackInspection = NonNullable<OfflinePackV1["inspections"]>[number];
|
||||
type PackTask = NonNullable<OfflinePackV1["tasks"]>[number];
|
||||
type PackQueen = NonNullable<OfflinePackV1["queens"]>[number];
|
||||
|
||||
export function packColony(id: string, code: string, overrides: Partial<PackColony> = {}): PackColony {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
apiaryId: "apiary-1",
|
||||
status: "ACTIVE",
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: "Zander",
|
||||
strength: 8,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
currentQueenId: null,
|
||||
notes: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function overviewRow(colony: PackColony, overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
|
||||
return {
|
||||
id: colony.id,
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
strength: colony.strength,
|
||||
broodStatus: colony.broodStatus,
|
||||
feedStatus: colony.feedStatus,
|
||||
healthStatus: colony.healthStatus,
|
||||
lastInspectionDate: new Date().toISOString(),
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packInspection(
|
||||
id: string,
|
||||
colonyId: string,
|
||||
date: string,
|
||||
overrides: Partial<PackInspection> = {},
|
||||
): PackInspection {
|
||||
return {
|
||||
id,
|
||||
colonyId,
|
||||
date,
|
||||
weather: null,
|
||||
temperature: null,
|
||||
colonyStrength: null,
|
||||
broodAmount: null,
|
||||
broodFrames: null,
|
||||
broodPattern: null,
|
||||
openBroodPresent: null,
|
||||
cappedBroodPresent: null,
|
||||
eggsPresent: null,
|
||||
queenSeen: null,
|
||||
queenCellsPresent: null,
|
||||
swarmMood: null,
|
||||
gentleness: null,
|
||||
combBehavior: null,
|
||||
feedStatus: null,
|
||||
honeySuperStatus: null,
|
||||
healthIssues: null,
|
||||
varroaIssues: null,
|
||||
measures: null,
|
||||
notes: null,
|
||||
createdAt: date,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packTask(id: string, title: string, overrides: Partial<PackTask> = {}): PackTask {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description: null,
|
||||
dueDate: null,
|
||||
priority: "NORMAL",
|
||||
status: "OPEN",
|
||||
relatedType: "COLONY",
|
||||
relatedId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packQueen(id: string, code: string, overrides: Partial<PackQueen> = {}): PackQueen {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
hatchYear: 2025,
|
||||
breed: "Carnica",
|
||||
markingColor: "Rot",
|
||||
markingNumber: "12",
|
||||
status: "ACTIVE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PackOptions {
|
||||
apiaryId?: string;
|
||||
apiaryName?: string;
|
||||
colonies?: PackColony[];
|
||||
queens?: OfflinePackV1["queens"];
|
||||
inspections?: OfflinePackV1["inspections"];
|
||||
tasks?: OfflinePackV1["tasks"];
|
||||
inspectionsVisible?: boolean;
|
||||
}
|
||||
|
||||
/** A pack as the server sends it: colonies, their overview rows and the sections. */
|
||||
export function offlinePack({
|
||||
apiaryId = "apiary-1",
|
||||
apiaryName = "Obstwiese",
|
||||
colonies = [packColony("colony-1", "A1", { apiaryId })],
|
||||
queens = [],
|
||||
inspections = [],
|
||||
tasks = [],
|
||||
inspectionsVisible = true,
|
||||
}: PackOptions = {}): OfflinePackV1 {
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: "2026-10-13T08:00:00.000Z",
|
||||
apiary: { id: apiaryId, name: apiaryName, location: "Hinterm Hof", status: "ACTIVE" },
|
||||
overview: {
|
||||
apiaryId,
|
||||
inspectionsVisible,
|
||||
rows: colonies.map((colony) => overviewRow(colony)),
|
||||
},
|
||||
colonies,
|
||||
queens,
|
||||
inspections,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export function storedPack(
|
||||
pack: OfflinePackV1,
|
||||
{ ownerUserId = "user-1", savedAt = new Date().toISOString() } = {},
|
||||
): StoredPack {
|
||||
return { apiaryId: pack.apiary.id, ownerUserId, savedAt, pack };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { getConnectivity } from "@/lib/offline/connectivity";
|
||||
import { getDb, type StoredPack } from "@/lib/offline/db";
|
||||
import type { OfflinePackV1 } from "./field-types";
|
||||
|
||||
/** From this age on a pack is marked as old: "bei Netz neu laden". */
|
||||
export const PACK_STALE_MS = 24 * 60 * 60 * 1000;
|
||||
/** Older packs are deleted - and never shown in the meantime. */
|
||||
export const PACK_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
/** After a sync, packs older than this are reloaded quietly. */
|
||||
export const PACK_AUTO_REFRESH_AFTER_MS = 60 * 60 * 1000;
|
||||
/** After a rejection, a pack listing the colony is reloaded from this age on. */
|
||||
export const PACK_FORCED_REFRESH_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Prefix of every query that reads packs (this device's storage only). */
|
||||
export const OFFLINE_PACKS_QUERY_KEY = ["offline-packs"] as const;
|
||||
|
||||
export type PackColony = OfflinePackV1["colonies"][number];
|
||||
export type PackInspection = NonNullable<OfflinePackV1["inspections"]>[number];
|
||||
export type PackTask = NonNullable<OfflinePackV1["tasks"]>[number];
|
||||
export type PackQueen = NonNullable<OfflinePackV1["queens"]>[number];
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function ageOf(pack: StoredPack, now: number): number {
|
||||
// An unreadable time counts as infinitely old: refreshed, marked, purged.
|
||||
const age = now - Date.parse(pack.savedAt);
|
||||
return Number.isNaN(age) ? Number.POSITIVE_INFINITY : age;
|
||||
}
|
||||
|
||||
function isExpired(pack: StoredPack, now: number): boolean {
|
||||
return ageOf(pack, now) > PACK_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
export function isPackStale(pack: StoredPack, now: number): boolean {
|
||||
return ageOf(pack, now) >= PACK_STALE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly the apiary fields the offline mode shows. Apiary locations are
|
||||
* theft-sensitive: should the server ever send coordinates, an address or
|
||||
* notes in a pack, they never reach the stored pack (IndexedDB). The service
|
||||
* worker separately never keeps the apiary answers that carry them
|
||||
* (/apiaries, /apiaries/:id - see public/sw-routes.js).
|
||||
*/
|
||||
function withoutApiaryLocation(pack: OfflinePackV1): OfflinePackV1 {
|
||||
const { id, name, location, status } = pack.apiary;
|
||||
return { ...pack, apiary: { id, name, location, status } };
|
||||
}
|
||||
|
||||
function isPackFor(value: unknown, apiaryId: string): value is OfflinePackV1 {
|
||||
const pack = value as Partial<OfflinePackV1> | null;
|
||||
return pack?.version === 1 && pack.apiary?.id === apiaryId && Array.isArray(pack.colonies);
|
||||
}
|
||||
|
||||
export async function fetchOfflinePack(apiaryId: string): Promise<OfflinePackV1> {
|
||||
const pack: unknown = await apiFetch<unknown>(
|
||||
`/apiaries/${encodeURIComponent(apiaryId)}/offline-pack`,
|
||||
);
|
||||
if (!isPackFor(pack, apiaryId)) {
|
||||
throw new Error("Unbekanntes Offline-Paket – bitte die App aktualisieren.");
|
||||
}
|
||||
return pack;
|
||||
}
|
||||
|
||||
/** Fetches the apiary's pack and keeps it on this device for `ownerUserId`. */
|
||||
export async function loadPack(apiaryId: string, ownerUserId: string): Promise<StoredPack> {
|
||||
const pack = withoutApiaryLocation(await fetchOfflinePack(apiaryId));
|
||||
const stored: StoredPack = {
|
||||
apiaryId,
|
||||
ownerUserId,
|
||||
savedAt: new Date().toISOString(),
|
||||
pack,
|
||||
};
|
||||
const db = await getDb();
|
||||
await db.put("offline-packs", stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** The apiary's pack, of whichever account loaded it; null when none or expired. */
|
||||
export async function getPack(apiaryId: string): Promise<StoredPack | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("offline-packs", apiaryId);
|
||||
return stored && !isExpired(stored, Date.now()) ? stored : null;
|
||||
}
|
||||
|
||||
/** The apiary's pack only if `ownerUserId` loaded it - another account's never counts. */
|
||||
export async function getOwnPack(apiaryId: string, ownerUserId: string): Promise<StoredPack | null> {
|
||||
const stored = await getPack(apiaryId);
|
||||
return stored?.ownerUserId === ownerUserId ? stored : null;
|
||||
}
|
||||
|
||||
/** Query key of getOwnPack (this device's storage, never the network). */
|
||||
export function ownPackQueryKey(ownerUserId: string | null, apiaryId: string) {
|
||||
return [...OFFLINE_PACKS_QUERY_KEY, ownerUserId, "apiary", apiaryId] as const;
|
||||
}
|
||||
|
||||
/** The packs `ownerUserId` loaded on this device, none expired. */
|
||||
export async function listPacks(ownerUserId: string): Promise<StoredPack[]> {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
return (await db.getAll("offline-packs")).filter(
|
||||
(stored) => stored.ownerUserId === ownerUserId && !isExpired(stored, now),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest of the given packs that lists the colony. A colony moved
|
||||
* between two loaded apiaries is where the later loaded pack says.
|
||||
*/
|
||||
export function packForColony(packs: readonly StoredPack[], colonyId: string): StoredPack | null {
|
||||
const holding = packs.filter((stored) =>
|
||||
stored.pack.colonies.some((colony) => colony.id === colonyId),
|
||||
);
|
||||
return [...holding].sort((a, b) => b.savedAt.localeCompare(a.savedAt))[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findPackForColony(
|
||||
colonyId: string,
|
||||
ownerUserId: string,
|
||||
): Promise<StoredPack | null> {
|
||||
return packForColony(await listPacks(ownerUserId), colonyId);
|
||||
}
|
||||
|
||||
export async function deleteAllPacks(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.clear("offline-packs");
|
||||
}
|
||||
|
||||
async function deletePack(apiaryId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("offline-packs", apiaryId);
|
||||
}
|
||||
|
||||
/** Deletes packs older than 14 days; returns how many. */
|
||||
export async function purgeOldPacks(now: number): Promise<number> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("offline-packs", "readwrite");
|
||||
const expired = (await tx.store.getAll()).filter((stored) => isExpired(stored, now));
|
||||
await Promise.all(expired.map((stored) => tx.store.delete(stored.apiaryId)));
|
||||
await tx.done;
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
/** The server no longer lets this user see the apiary (or it is gone). */
|
||||
function isRevoked(error: unknown): boolean {
|
||||
return error instanceof ApiError && (error.status === 403 || error.status === 404);
|
||||
}
|
||||
|
||||
export interface PackRefreshOptions {
|
||||
/** The server rejected entries: reload the packs of their colonies now. */
|
||||
force: true;
|
||||
/** Colonies of the rejected entries. */
|
||||
colonyIds: readonly string[];
|
||||
}
|
||||
|
||||
function isDue(stored: StoredPack, now: number, options?: PackRefreshOptions): boolean {
|
||||
const age = ageOf(stored, now);
|
||||
if (age > PACK_AUTO_REFRESH_AFTER_MS) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
options?.force === true &&
|
||||
age > PACK_FORCED_REFRESH_AFTER_MS &&
|
||||
stored.pack.colonies.some((colony) => options.colonyIds.includes(colony.id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a sync run: reloads the user's packs older than 1 h, so what the
|
||||
* server has just accepted or rejected (a colony ended meanwhile) shows up
|
||||
* offline too. With `force`, the packs listing a rejected entry's colony are
|
||||
* reloaded from 5 min of age on. Quietly - it never throws. A pack the user
|
||||
* may no longer read is deleted; at the first network error it stops (try
|
||||
* again next run).
|
||||
*/
|
||||
export async function refreshPacksAfterSync(
|
||||
ownerUserId: string,
|
||||
now: number,
|
||||
isCancelled: () => boolean = () => false,
|
||||
options?: PackRefreshOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await purgeOldPacks(now);
|
||||
if (getConnectivity() === "offline") {
|
||||
return;
|
||||
}
|
||||
const due = (await listPacks(ownerUserId)).filter((stored) => isDue(stored, now, options));
|
||||
for (const stored of due) {
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadPack(stored.apiaryId, ownerUserId);
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return;
|
||||
}
|
||||
if (isRevoked(error)) {
|
||||
await deletePack(stored.apiaryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Housekeeping only: the next sync run tries again.
|
||||
}
|
||||
}
|
||||
|
||||
/** Living colonies of all given packs, each once (newest pack wins), by code. */
|
||||
export function livingColoniesFromPacks(packs: readonly StoredPack[]): PackColony[] {
|
||||
const newestFirst = [...packs].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
const byId = new Map<string, PackColony>();
|
||||
for (const stored of newestFirst) {
|
||||
for (const colony of stored.pack.colonies) {
|
||||
if (!byId.has(colony.id) && isLivingColony(colony)) {
|
||||
byId.set(colony.id, colony);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) =>
|
||||
a.code.localeCompare(b.code, "de", { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colony's inspections in its pack (the last three); null when no pack
|
||||
* lists the colony or the user may not read inspections.
|
||||
*/
|
||||
export function inspectionsFromPacks(
|
||||
packs: readonly StoredPack[],
|
||||
colonyId: string,
|
||||
): PackInspection[] | null {
|
||||
const inspections = packForColony(packs, colonyId)?.pack.inspections ?? null;
|
||||
return inspections?.filter((inspection) => inspection.colonyId === colonyId) ?? null;
|
||||
}
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** "vor 5 Minuten" - how long ago the pack was loaded. */
|
||||
export function packAgeText(savedAt: string, now: number): string {
|
||||
const age = Math.max(0, now - Date.parse(savedAt));
|
||||
if (age < MINUTE_MS) {
|
||||
return "vor weniger als einer Minute";
|
||||
}
|
||||
if (age < HOUR_MS) {
|
||||
return `vor ${plural(Math.floor(age / MINUTE_MS), "Minute", "Minuten")}`;
|
||||
}
|
||||
if (age < DAY_MS) {
|
||||
return `vor ${plural(Math.floor(age / HOUR_MS), "Stunde", "Stunden")}`;
|
||||
}
|
||||
return `vor ${plural(Math.floor(age / DAY_MS), "Tag", "Tagen")}`;
|
||||
}
|
||||
|
||||
/** The local calendar day a pack was loaded, as "13.10.2026". */
|
||||
export function packDayText(savedAt: string): string {
|
||||
const date = new Date(savedAt);
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${day}.${month}.${date.getFullYear()}`;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { colonyOverviewKey, fetchColonyOverview } from "./field-api";
|
||||
|
||||
/** The apiary's living colonies with their traffic-light inputs. */
|
||||
/**
|
||||
* The apiary's living colonies with their traffic-light inputs - offline
|
||||
* from the loaded pack, so the apiary page and the stand round
|
||||
* (`stillInApiary`) keep working at the apiary.
|
||||
*/
|
||||
export function useColonyOverview(apiaryId: string, enabled = true) {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: colonyOverviewKey(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId, userId),
|
||||
enabled,
|
||||
// Without network the answer comes from this device's storage.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAccessToken, setAccessToken } from './token-store';
|
||||
import { acceptRefreshedToken, getAccessToken } from './token-store';
|
||||
import { refreshRequest } from './api';
|
||||
import { getApiUrl } from '../config/runtime-config';
|
||||
import {
|
||||
@@ -63,8 +63,7 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
@@ -114,8 +113,7 @@ export async function apiFetchFormData<T>(
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetchFormData(path, formData, options);
|
||||
}
|
||||
}
|
||||
@@ -132,8 +130,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetch(path, init);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
|
||||
isNetworkError,
|
||||
} from "@/lib/http/fetch-with-timeout";
|
||||
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
|
||||
import {
|
||||
@@ -42,3 +43,41 @@ describe("auth api timeouts", () => {
|
||||
await expectTimeoutAt(() => getCurrentUserRequest("tok"), DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshRequest outcome", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function answerWith(status: number, body: unknown = {}) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })),
|
||||
);
|
||||
}
|
||||
|
||||
it("returns the access token when the server accepts the session", async () => {
|
||||
answerWith(200, { accessToken: "token-1" });
|
||||
|
||||
await expect(refreshRequest()).resolves.toEqual({ accessToken: "token-1" });
|
||||
});
|
||||
|
||||
it.each([401, 403])("says the session is over for %i", async (status) => {
|
||||
answerWith(status);
|
||||
|
||||
await expect(refreshRequest()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])(
|
||||
"does not end the session for %i - the server was just unavailable",
|
||||
async (status) => {
|
||||
answerWith(status);
|
||||
|
||||
const failure = await refreshRequest().catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
// Treated like a lost connection by everything that retries or queues.
|
||||
expect(isNetworkError(failure)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -69,9 +69,15 @@ async function performRefresh(): Promise<AccessTokenResponse | null> {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Only 401/403 say the session is over. Anything else (429, 5xx from the
|
||||
// server or a proxy) is a server that cannot answer right now: a TypeError,
|
||||
// so it counts as a lost connection and never as an ended session.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new TypeError(`Refresh unavailable (HTTP ${response.status}).`);
|
||||
}
|
||||
return response.json() as Promise<AccessTokenResponse>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { getDb, queueInspection, summarizeQueue } from "@/lib/offline/db";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import { cancelSync, resumeSync, syncPendingEntries } from "@/lib/offline/sync";
|
||||
import {
|
||||
getCurrentUserRequest,
|
||||
loginRequest,
|
||||
logoutRequest,
|
||||
refreshRequest,
|
||||
verifyMfaRequest,
|
||||
} from "./api";
|
||||
import { AuthProvider, useAuth, type AuthUser } from "./auth-context";
|
||||
import {
|
||||
isLogoutPending,
|
||||
loadIdentity,
|
||||
loadLastUserId,
|
||||
markLogoutPending,
|
||||
saveIdentity,
|
||||
saveLastUserId,
|
||||
} from "./offline-identity";
|
||||
import { removeOtherUsersData, restoreOfflineShell, wipeDeviceData } from "./session-cleanup";
|
||||
import { acceptRefreshedToken, getAccessToken, setAccessToken } from "./token-store";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./api")>()),
|
||||
refreshRequest: vi.fn(),
|
||||
getCurrentUserRequest: vi.fn(),
|
||||
loginRequest: vi.fn(),
|
||||
logoutRequest: vi.fn(),
|
||||
verifyMfaRequest: vi.fn(),
|
||||
ssoCompleteRequest: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({
|
||||
cancelSync: vi.fn().mockResolvedValue(undefined),
|
||||
resumeSync: vi.fn(),
|
||||
syncPendingEntries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/photos/store", () => ({ finishHandOvers: vi.fn().mockResolvedValue(0) }));
|
||||
vi.mock("./session-cleanup", () => ({
|
||||
wipeDeviceData: vi.fn().mockResolvedValue(true),
|
||||
removeOtherUsersData: vi.fn().mockResolvedValue(undefined),
|
||||
restoreOfflineShell: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const ALICE: AuthUser = {
|
||||
id: "user-alice",
|
||||
email: "[email protected]",
|
||||
name: "Alice",
|
||||
roleKey: "admin",
|
||||
roleName: "Admin",
|
||||
permissions: ["colonies.read"],
|
||||
mfaEnabled: false,
|
||||
hasAvatar: false,
|
||||
};
|
||||
const BOB: AuthUser = { ...ALICE, id: "user-bob", email: "[email protected]", name: "Bob" };
|
||||
const ACCESS_TOKEN = "secret-access-token-4711";
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-14T00:00:00.000Z" };
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const refreshMock = vi.mocked(refreshRequest);
|
||||
const profileMock = vi.mocked(getCurrentUserRequest);
|
||||
const loginMock = vi.mocked(loginRequest);
|
||||
const logoutRequestMock = vi.mocked(logoutRequest);
|
||||
const cancelSyncMock = vi.mocked(cancelSync);
|
||||
const wipeMock = vi.mocked(wipeDeviceData);
|
||||
|
||||
let latest: ReturnType<typeof useAuth>;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
function Probe() {
|
||||
const auth = useAuth();
|
||||
// After every committed render, so `latest` is what the screen shows.
|
||||
useEffect(() => {
|
||||
latest = auth;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderAuth(): Promise<void> {
|
||||
queryClient = new QueryClient();
|
||||
vi.spyOn(queryClient, "clear");
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Probe />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(latest.isLoading).toBe(false));
|
||||
}
|
||||
|
||||
const NETWORK_FAILURE = new TypeError("Failed to fetch");
|
||||
|
||||
function serverKnowsAlice(): void {
|
||||
refreshMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
}
|
||||
|
||||
async function emptyDevice(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.all([...db.objectStoreNames].map((name) => db.clear(name)));
|
||||
window.localStorage.clear();
|
||||
}
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
cancelSyncMock.mockResolvedValue(undefined);
|
||||
wipeMock.mockResolvedValue(true);
|
||||
logoutRequestMock.mockResolvedValue(undefined);
|
||||
setAccessToken(null);
|
||||
reportRequestOutcome("reached-server");
|
||||
await emptyDevice();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
describe("cold start", () => {
|
||||
it("signs in with a fresh refresh and keeps who it is for offline", async () => {
|
||||
serverKnowsAlice();
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("online");
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
expect(await loadLastUserId()).toBe(ALICE.id);
|
||||
// Sync stays closed until the offline band opens it for this user.
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cold start offline with a cached identity shows the app in offline mode", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cold start offline without or with an expired identity goes to login", async () => {
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await renderAuth();
|
||||
expect(latest.user).toBeNull();
|
||||
|
||||
await saveIdentity(ALICE, Date.now() - 8 * DAY_MS);
|
||||
cleanup();
|
||||
await renderAuth();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("cold start with a revoked session goes to login, drops the identity and keeps the queue", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
refreshMock.mockResolvedValue(null);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
});
|
||||
|
||||
it("a pending logout revokes the session before any refresh", async () => {
|
||||
markLogoutPending();
|
||||
serverKnowsAlice();
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(logoutRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("stays signed out and keeps the pending logout while the network is still down", async () => {
|
||||
markLogoutPending();
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
await saveIdentity(ALICE);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
expect(latest.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("offline session", () => {
|
||||
async function startOfflineAsAlice(): Promise<void> {
|
||||
await saveIdentity(ALICE);
|
||||
await saveLastUserId(ALICE.id);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValueOnce(NETWORK_FAILURE);
|
||||
await renderAuth();
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
}
|
||||
|
||||
it("back online with a 401 refresh sends the user to login and keeps the queue", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
await queueInspection(INSPECTION, "key-2", ALICE.id);
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockResolvedValue(null);
|
||||
cancelSyncMock.mockClear();
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.user).toBeNull());
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Sitzung abgelaufen – bitte neu anmelden. 2 Einträge warten und werden danach gesendet.",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 2 });
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("back online with a refresh the server could not answer stays offline and keeps the session", async () => {
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockRejectedValue(new TypeError("Refresh unavailable (HTTP 503)."));
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("back online with a valid session gets a token and switches to online", async () => {
|
||||
await startOfflineAsAlice();
|
||||
serverKnowsAlice();
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.sessionMode).toBe("online"));
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(wipeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays offline when the refresh cannot reach the server yet", async () => {
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("another account behind the refresh cookie is a user switch: the device is wiped", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.user).toEqual(BOB));
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: BOB.id, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh outcomes that are not a verdict", () => {
|
||||
it("cold start with a refresh the server could not answer (503) opens the offline session", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
refreshMock.mockRejectedValue(new TypeError("Refresh unavailable (HTTP 503)."));
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a silent refresh in the api client", () => {
|
||||
beforeEach(async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
setAccessToken(ACCESS_TOKEN);
|
||||
});
|
||||
|
||||
it("keeps the signed-in account going when the refreshed token is theirs", async () => {
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await expect(acceptRefreshedToken("fresh-token")).resolves.toBe(true);
|
||||
|
||||
expect(getAccessToken()).toBe("fresh-token");
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("ends the session when another account token comes out (second tab signed in as someone else)", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
await act(async () => {
|
||||
await expect(acceptRefreshedToken("bobs-token")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("anderes Konto"));
|
||||
// The first account entries are never sent under the second.
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(await summarizeQueue(BOB.id)).toMatchObject({ own: 0, foreign: 1 });
|
||||
});
|
||||
|
||||
it("does not revive a session nobody is signed in to", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
await expect(acceptRefreshedToken("stray-token")).resolves.toBe(false);
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a sign-in or reconnect that is overtaken", () => {
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it("does not take a token into use when the person logs out during an in-flight reconnect", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValueOnce(NETWORK_FAILURE);
|
||||
await renderAuth();
|
||||
const refresh = deferred<{ accessToken: string } | null>();
|
||||
refreshMock.mockReturnValue(refresh.promise);
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
await act(async () => {
|
||||
refresh.resolve({ accessToken: ACCESS_TOKEN });
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not take a token into use when the person logs out during an in-flight sign-in", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
const profile = deferred<AuthUser>();
|
||||
profileMock.mockReturnValue(profile.promise);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
|
||||
let signingIn: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
signingIn = latest.login("[email protected]", "pw");
|
||||
});
|
||||
await waitFor(() => expect(profileMock).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
await act(async () => {
|
||||
profile.resolve(ALICE);
|
||||
await signingIn;
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("prepares the device before the new token is in use", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
let tokenDuringWipe: string | null = "unset";
|
||||
wipeMock.mockImplementation(async () => {
|
||||
tokenDuringWipe = getAccessToken();
|
||||
return true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(tokenDuringWipe).toBeNull();
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
});
|
||||
|
||||
it("refills the service worker after a sign-in", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(restoreOfflineShell).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
beforeEach(async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("closes sync before the token changes", async () => {
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
let tokenWhenSyncWasClosed: string | null = "unset";
|
||||
cancelSyncMock.mockImplementation(async () => {
|
||||
tokenWhenSyncWasClosed = getAccessToken();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(tokenWhenSyncWasClosed).toBeNull();
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logging in as another user wipes the previous user's packs and caches but keeps their queue unsent", async () => {
|
||||
await saveLastUserId(ALICE.id);
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: BOB.id, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(await loadLastUserId()).toBe(BOB.id);
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
expect(await summarizeQueue(BOB.id)).toMatchObject({ own: 0, foreign: 1 });
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the same user logging in again keeps their data and only drops what others left unsaved", async () => {
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(wipeMock).not.toHaveBeenCalled();
|
||||
expect(removeOtherUsersData).toHaveBeenCalledWith(ALICE.id);
|
||||
});
|
||||
|
||||
it("refuses to sign in while an earlier logout could not be sent", async () => {
|
||||
markLogoutPending();
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await act(async () => {
|
||||
await expect(latest.login("[email protected]", "pw")).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
expect(loginMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
});
|
||||
|
||||
it("revokes an earlier pending logout first, then signs in", async () => {
|
||||
markLogoutPending();
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(logoutRequestMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loginMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes sync before an MFA login changes the token as well", async () => {
|
||||
vi.mocked(verifyMfaRequest).mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.verifyMfa("mfa-token", "123456");
|
||||
});
|
||||
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("never writes the access token to localStorage or IndexedDB", async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const idbWrites = [
|
||||
vi.spyOn(IDBObjectStore.prototype, "put"),
|
||||
vi.spyOn(IDBObjectStore.prototype, "add"),
|
||||
];
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
const written = [...setItem.mock.calls, ...idbWrites.flatMap((spy) => spy.mock.calls)];
|
||||
expect(written.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(written)).not.toContain(ACCESS_TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
beforeEach(async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("closes sync, revokes the session, then clears the token and the device", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(cancelSyncMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
logoutRequestMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await loadLastUserId()).toBeNull();
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardQueueOf: undefined });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
// Sync stays closed: the next token opens it again.
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the account's own queue only when asked to", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout({ discardQueue: true });
|
||||
});
|
||||
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardQueueOf: ALICE.id });
|
||||
});
|
||||
|
||||
it("logout without network marks the logout as pending and still signs out", async () => {
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the layout mounted for the old user until the user is gone", async () => {
|
||||
let isLoadingDuringLogout = false;
|
||||
logoutRequestMock.mockImplementation(async () => {
|
||||
isLoadingDuringLogout = latest.isLoading;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(isLoadingDuringLogout).toBe(false);
|
||||
expect(latest.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("tells the person when the device could not be cleaned completely", async () => {
|
||||
wipeMock.mockResolvedValue(false);
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
// The next sign-in of anyone cleans again.
|
||||
expect(await loadLastUserId()).toBe(ALICE.id);
|
||||
});
|
||||
|
||||
it("reopens sync when the logout itself fails before the user is gone", async () => {
|
||||
cancelSyncMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await act(async () => {
|
||||
await expect(latest.logout()).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
expect(resumeSync).toHaveBeenCalled();
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshUser keeps the cached identity current without closing sync", async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
profileMock.mockResolvedValue({ ...ALICE, name: "Alice Neu" });
|
||||
|
||||
await act(async () => {
|
||||
await latest.refreshUser();
|
||||
});
|
||||
|
||||
expect(latest.user?.name).toBe("Alice Neu");
|
||||
expect((await loadIdentity())?.name).toBe("Alice Neu");
|
||||
expect(cancelSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { clearAllFormDrafts } from '@/lib/forms/draft-storage';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { isNetworkError } from '@/lib/http/fetch-with-timeout';
|
||||
import { useConnectivity } from '@/lib/offline/connectivity';
|
||||
import { summarizeQueue } from '@/lib/offline/db';
|
||||
import { sessionExpiredWithEntries } from '@/lib/offline/queue-texts';
|
||||
import { cancelSync, resumeSync } from '@/lib/offline/sync';
|
||||
import { finishHandOvers } from '@/lib/photos/store';
|
||||
import {
|
||||
getCurrentUserRequest,
|
||||
isMfaRequired,
|
||||
@@ -11,7 +26,23 @@ import {
|
||||
ssoCompleteRequest,
|
||||
verifyMfaRequest,
|
||||
} from './api';
|
||||
import { getAccessToken, setAccessToken as storeAccessToken } from './token-store';
|
||||
import {
|
||||
clearIdentity,
|
||||
clearLastUserId,
|
||||
clearLogoutPending,
|
||||
isLogoutPending,
|
||||
loadIdentity,
|
||||
loadLastUserId,
|
||||
markLogoutPending,
|
||||
saveIdentity,
|
||||
saveLastUserId,
|
||||
} from './offline-identity';
|
||||
import { removeOtherUsersData, restoreOfflineShell, wipeDeviceData } from './session-cleanup';
|
||||
import {
|
||||
getAccessToken,
|
||||
setAccessToken as storeAccessToken,
|
||||
setRefreshedTokenHandler,
|
||||
} from './token-store';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -26,104 +57,459 @@ export interface AuthUser {
|
||||
|
||||
export type LoginOutcome = { mfaRequired: false } | { mfaRequired: true; mfaToken: string };
|
||||
|
||||
/**
|
||||
* online: a refresh succeeded and an access token is held in memory.
|
||||
* offline: opened from the cached identity - no token, so nothing can be sent
|
||||
* to the server until a refresh succeeds again.
|
||||
*/
|
||||
export type SessionMode = 'online' | 'offline';
|
||||
|
||||
export interface LogoutOptions {
|
||||
/** Also delete the unsent entries of this account (the person confirmed it). */
|
||||
discardQueue?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
sessionMode: SessionMode;
|
||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||
completeSso: () => Promise<LoginOutcome>;
|
||||
logout: () => Promise<void>;
|
||||
logout: (options?: LogoutOptions) => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
user: AuthUser | null;
|
||||
mode: SessionMode;
|
||||
}
|
||||
|
||||
const SIGNED_OUT: Session = { user: null, mode: 'online' };
|
||||
// While offline: how often to look for the server again.
|
||||
const RECONNECT_INTERVAL_MS = 30_000;
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
async function applyAccessToken(token: string): Promise<AuthUser | null> {
|
||||
storeAccessToken(token);
|
||||
/**
|
||||
* Loads the profile that belongs to a new access token. The token itself is
|
||||
* only taken into use (storeAccessToken) once the device is prepared for that
|
||||
* user. Sync is closed first: nothing queued may be sent under the old token
|
||||
* meanwhile. The user is null when the profile cannot be loaded; a network
|
||||
* failure is thrown.
|
||||
*/
|
||||
async function loadProfile(token: string): Promise<AuthUser | null> {
|
||||
await cancelSync();
|
||||
try {
|
||||
return await getCurrentUserRequest(token);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Revokes a session whose logout could not reach the server earlier. */
|
||||
async function settlePendingLogout(): Promise<boolean> {
|
||||
if (!isLogoutPending()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await logoutRequest();
|
||||
clearLogoutPending();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openOfflineSession(): Promise<Session> {
|
||||
storeAccessToken(null);
|
||||
const identity = await loadIdentity().catch(() => null);
|
||||
return identity ? { user: identity, mode: 'offline' } : SIGNED_OUT;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const connectivity = useConnectivity();
|
||||
const [session, setSession] = useState<Session>(SIGNED_OUT);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const sessionRef = useRef<Session>(SIGNED_OUT);
|
||||
// A logout's cleaning of the device runs on after the user is gone; nobody
|
||||
// signs in before it is done.
|
||||
const cleaningRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const isReconnectingRef = useRef(false);
|
||||
// Raised whenever the session changes or ends. A sign-in or reconnect that
|
||||
// is still on its way checks it after every wait and gives up when it moved.
|
||||
const epochRef = useRef(0);
|
||||
|
||||
const commit = useCallback((next: Session) => {
|
||||
epochRef.current += 1;
|
||||
sessionRef.current = next;
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Makes `user` the person this device works for. Another account than the
|
||||
* last one is a user switch: the previous person's packs, caches, drafts
|
||||
* and in-memory data are removed (their queue stays, and is never sent
|
||||
* under this account).
|
||||
*/
|
||||
const prepareDevice = useCallback(
|
||||
async (user: AuthUser): Promise<boolean> => {
|
||||
const lastUserId = await loadLastUserId().then(
|
||||
(id) => ({ id, known: true }),
|
||||
() => ({ id: null, known: false }),
|
||||
);
|
||||
const isSwitch = !lastUserId.known || (lastUserId.id !== null && lastUserId.id !== user.id);
|
||||
if (isSwitch) {
|
||||
// Owner-less legacy entries would be sent under this account.
|
||||
await wipeDeviceData({ keepDraftsOf: user.id, discardOwnerless: true });
|
||||
queryClient.clear();
|
||||
} else {
|
||||
await removeOtherUsersData(user.id).catch(() => undefined);
|
||||
}
|
||||
return isSwitch;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
/** Only once the session is certain: an aborted sign-in leaves no identity behind. */
|
||||
const takeIntoUse = useCallback(
|
||||
async (token: string, user: AuthUser, isSwitch: boolean) => {
|
||||
storeAccessToken(token);
|
||||
await Promise.allSettled([saveLastUserId(user.id), saveIdentity(user)]);
|
||||
commit({ user, mode: 'online' });
|
||||
if (isSwitch) {
|
||||
void restoreOfflineShell();
|
||||
}
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const signInWithToken = useCallback(
|
||||
async (token: string) => {
|
||||
const epoch = epochRef.current;
|
||||
await cleaningRef.current;
|
||||
const user = await loadProfile(token);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
commit(SIGNED_OUT);
|
||||
return;
|
||||
}
|
||||
await prepareDevice(user);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
// A sign-in always refills the worker: the wipe of the last logout may
|
||||
// have emptied it, and the backend address never passes it again.
|
||||
await takeIntoUse(token, user, true);
|
||||
},
|
||||
[commit, prepareDevice, takeIntoUse],
|
||||
);
|
||||
|
||||
/** The session is over on the server: back to login, the queue stays. */
|
||||
const expireSession = useCallback(async () => {
|
||||
epochRef.current += 1;
|
||||
await cancelSync();
|
||||
storeAccessToken(null);
|
||||
const userId = sessionRef.current.user?.id;
|
||||
const waiting = userId
|
||||
? await summarizeQueue(userId).then(
|
||||
(summary) => summary.own + summary.failed,
|
||||
() => 0,
|
||||
)
|
||||
: 0;
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
toast.error(sessionExpiredWithEntries(waiting), { duration: 10_000 });
|
||||
commit(SIGNED_OUT);
|
||||
}, [commit, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRequest()
|
||||
.then(async (result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
let isCurrent = true;
|
||||
|
||||
async function start(): Promise<Session> {
|
||||
// Nothing is sent until a token is set and the offline band reopens sync.
|
||||
await cancelSync();
|
||||
if (isLogoutPending()) {
|
||||
await settlePendingLogout();
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await refreshRequest();
|
||||
} catch {
|
||||
return openOfflineSession();
|
||||
}
|
||||
if (!refreshed) {
|
||||
await clearIdentity().catch(() => undefined);
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
try {
|
||||
const user = await loadProfile(refreshed.accessToken);
|
||||
if (!user) {
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string): Promise<LoginOutcome> => {
|
||||
const result = await loginRequest(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
const isSwitch = await prepareDevice(user);
|
||||
storeAccessToken(refreshed.accessToken);
|
||||
await Promise.allSettled([saveLastUserId(user.id), saveIdentity(user)]);
|
||||
if (isSwitch) {
|
||||
void restoreOfflineShell();
|
||||
}
|
||||
return { user, mode: 'online' };
|
||||
} catch {
|
||||
return openOfflineSession();
|
||||
}
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
return { mfaRequired: false };
|
||||
|
||||
start()
|
||||
.catch(() => SIGNED_OUT)
|
||||
.then((next) => {
|
||||
if (isCurrent) {
|
||||
commit(next);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [commit, prepareDevice]);
|
||||
|
||||
// Offline session: look for the server again, and take a token as soon as
|
||||
// the refresh works. A refresh the server refuses ends the session.
|
||||
const reconnect = useCallback(async () => {
|
||||
if (isReconnectingRef.current || sessionRef.current.mode !== 'offline') {
|
||||
return;
|
||||
}
|
||||
isReconnectingRef.current = true;
|
||||
const epoch = epochRef.current;
|
||||
try {
|
||||
const refreshed = await refreshRequest().catch(() => undefined);
|
||||
// No answer, or the person signed out / switched while waiting.
|
||||
if (refreshed === undefined || epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!refreshed) {
|
||||
await expireSession();
|
||||
return;
|
||||
}
|
||||
const user = await loadProfile(refreshed.accessToken);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
await expireSession();
|
||||
return;
|
||||
}
|
||||
const isSwitch = await prepareDevice(user);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
await takeIntoUse(refreshed.accessToken, user, isSwitch);
|
||||
} catch {
|
||||
// The server went away again: still an offline session.
|
||||
} finally {
|
||||
isReconnectingRef.current = false;
|
||||
}
|
||||
}, [expireSession, prepareDevice, takeIntoUse]);
|
||||
|
||||
const isOfflineSession = session.mode === 'offline' && session.user !== null;
|
||||
useEffect(() => {
|
||||
if (!isOfflineSession) {
|
||||
return;
|
||||
}
|
||||
// Not in the effect body itself: a refresh result sets state.
|
||||
const first = connectivity === 'online' ? setTimeout(() => void reconnect(), 0) : undefined;
|
||||
const handleOnline = () => void reconnect();
|
||||
window.addEventListener('online', handleOnline);
|
||||
const timer = setInterval(() => {
|
||||
if (navigator.onLine !== false) {
|
||||
void reconnect();
|
||||
}
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
return () => {
|
||||
clearTimeout(first);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [isOfflineSession, connectivity, reconnect]);
|
||||
|
||||
// A silent refresh in the api client (after a 401) may hand out a token of
|
||||
// another account: the refresh cookie is shared by all tabs. Only the
|
||||
// account this tab shows may continue; anyone else ends this session here.
|
||||
useEffect(() => {
|
||||
setRefreshedTokenHandler(async (token) => {
|
||||
const current = sessionRef.current.user;
|
||||
if (!current) {
|
||||
// Nobody is signed in: a refresh must not revive a session.
|
||||
return false;
|
||||
}
|
||||
const epoch = epochRef.current;
|
||||
const profile = await getCurrentUserRequest(token).catch((error: unknown) => {
|
||||
if (isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (epochRef.current !== epoch || !profile) {
|
||||
return false;
|
||||
}
|
||||
if (profile.id === current.id) {
|
||||
storeAccessToken(token);
|
||||
if (sessionRef.current.mode === 'offline') {
|
||||
commit({ user: profile, mode: 'online' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
epochRef.current += 1;
|
||||
await cancelSync();
|
||||
storeAccessToken(null);
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
commit(SIGNED_OUT);
|
||||
toast.error('Ein anderes Konto wurde angemeldet – bitte neu anmelden.');
|
||||
const isClean = await wipeDeviceData({ keepDraftsOf: null, discardOwnerless: true });
|
||||
if (!isClean) {
|
||||
toast.error('Nicht alle Daten konnten von diesem Gerät gelöscht werden.');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return () => setRefreshedTokenHandler(null);
|
||||
}, [commit, queryClient]);
|
||||
|
||||
// A logout the server never heard of is revoked at the next network contact.
|
||||
useEffect(() => {
|
||||
const handleOnline = () => void settlePendingLogout();
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => window.removeEventListener('online', handleOnline);
|
||||
}, []);
|
||||
|
||||
const verifyMfa = useCallback(async (mfaToken: string, code: string) => {
|
||||
const result = await verifyMfaRequest(mfaToken, code);
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
/**
|
||||
* A new sign-in starts by revoking an unsent logout: with the old cookie
|
||||
* still alive, the pending logout would otherwise end the new session.
|
||||
*/
|
||||
const beginSignIn = useCallback(async () => {
|
||||
await cleaningRef.current;
|
||||
if (!(await settlePendingLogout())) {
|
||||
throw new TypeError('A pending logout has not reached the server yet.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string): Promise<LoginOutcome> => {
|
||||
await beginSignIn();
|
||||
const result = await loginRequest(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
await signInWithToken(result.accessToken);
|
||||
return { mfaRequired: false };
|
||||
},
|
||||
[beginSignIn, signInWithToken],
|
||||
);
|
||||
|
||||
const verifyMfa = useCallback(
|
||||
async (mfaToken: string, code: string) => {
|
||||
const result = await verifyMfaRequest(mfaToken, code);
|
||||
await signInWithToken(result.accessToken);
|
||||
},
|
||||
[signInWithToken],
|
||||
);
|
||||
|
||||
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
|
||||
await beginSignIn();
|
||||
const result = await ssoCompleteRequest();
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
await signInWithToken(result.accessToken);
|
||||
return { mfaRequired: false };
|
||||
}, []);
|
||||
}, [beginSignIn, signInWithToken]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutRequest();
|
||||
storeAccessToken(null);
|
||||
setUser(null);
|
||||
// Unfinished form entries belong to the person who typed them.
|
||||
clearAllFormDrafts();
|
||||
// The service worker cache now holds real business data (colonies,
|
||||
// tasks, etc.) for offline viewing - on a shared/handed-off device
|
||||
// that must not survive into the next person's session.
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
}, []);
|
||||
const logout = useCallback(
|
||||
async ({ discardQueue = false }: LogoutOptions = {}) => {
|
||||
const current = sessionRef.current.user;
|
||||
let isSignedOut = false;
|
||||
epochRef.current += 1;
|
||||
try {
|
||||
// Nothing may be sent under a session that is being ended.
|
||||
await cancelSync();
|
||||
if (current) {
|
||||
// Photos of an online save that is not handed over yet would be
|
||||
// deleted with the drafts below.
|
||||
await finishHandOvers(current.id).catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
await logoutRequest();
|
||||
clearLogoutPending();
|
||||
} catch {
|
||||
// No network: the cookie lives on. Revoked at the next contact.
|
||||
markLogoutPending();
|
||||
}
|
||||
storeAccessToken(null);
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
// The user is gone before the device is cleaned: nothing of theirs
|
||||
// stays on screen while the caches are deleted.
|
||||
commit(SIGNED_OUT);
|
||||
isSignedOut = true;
|
||||
const cleaning = wipeDeviceData({
|
||||
keepDraftsOf: null,
|
||||
discardQueueOf: discardQueue && current ? current.id : undefined,
|
||||
});
|
||||
cleaningRef.current = cleaning;
|
||||
const isClean = await cleaning;
|
||||
if (isClean) {
|
||||
await clearLastUserId().catch(() => undefined);
|
||||
} else {
|
||||
toast.error('Nicht alle Daten konnten von diesem Gerät gelöscht werden.');
|
||||
}
|
||||
} finally {
|
||||
if (!isSignedOut) {
|
||||
// The logout was not carried out: the signed-in user keeps syncing.
|
||||
resumeSync();
|
||||
}
|
||||
}
|
||||
},
|
||||
[commit, queryClient],
|
||||
);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission: string) => user?.permissions.includes(permission) ?? false,
|
||||
[user],
|
||||
(permission: string) => session.user?.permissions.includes(permission) ?? false,
|
||||
[session.user],
|
||||
);
|
||||
|
||||
// Re-fetches the profile with the access token already held in memory -
|
||||
// no need to rotate the refresh-token cookie just to reflect a self-edit
|
||||
// (name/email/avatar/MFA change) in the cached user.
|
||||
// (name/email/avatar/MFA change) in the cached user. The token stays the
|
||||
// same, so sync stays open.
|
||||
const refreshUser = useCallback(async () => {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setUser(await applyAccessToken(token));
|
||||
}, []);
|
||||
try {
|
||||
const user = await getCurrentUserRequest(token);
|
||||
commit({ user, mode: sessionRef.current.mode });
|
||||
await saveIdentity(user).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (!isNetworkError(error)) {
|
||||
commit(SIGNED_OUT);
|
||||
}
|
||||
}
|
||||
}, [commit]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
user: session.user,
|
||||
isLoading,
|
||||
sessionMode: session.mode,
|
||||
login,
|
||||
verifyMfa,
|
||||
completeSso,
|
||||
@@ -142,6 +528,11 @@ export function useCurrentUserId(): string | null {
|
||||
return useContext(AuthContext)?.user?.id ?? null;
|
||||
}
|
||||
|
||||
/** online, unless this is an offline session - usable outside an AuthProvider (tests). */
|
||||
export function useSessionMode(): SessionMode {
|
||||
return useContext(AuthContext)?.sessionMode ?? 'online';
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type DbModule = typeof import("@/lib/offline/db");
|
||||
type LegacyModule = typeof import("./legacy-queue");
|
||||
|
||||
let db: DbModule;
|
||||
let legacy: LegacyModule;
|
||||
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z" };
|
||||
const TREATMENT = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" };
|
||||
|
||||
/** An entry queued before owners existed (DB v2): it has no ownerUserId. */
|
||||
async function queueOwnerless(): Promise<void> {
|
||||
const connection = await db.getDb();
|
||||
await connection.add("pending-inspections", {
|
||||
input: INSPECTION,
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("owner-less queue entries", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/offline/db");
|
||||
legacy = await import("./legacy-queue");
|
||||
});
|
||||
|
||||
it("deletes only the owner-less entries", async () => {
|
||||
await queueOwnerless();
|
||||
await db.queueTreatment(TREATMENT, "key-legacy-sibling", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-own", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-foreign", "user-2");
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toMatchObject({ own: 3 });
|
||||
|
||||
await legacy.clearOwnerlessEntries();
|
||||
|
||||
// Own and foreign entries stay; the owner-less one is gone.
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 0, foreign: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getDb, type QueueStoreName } from "@/lib/offline/db";
|
||||
|
||||
/*
|
||||
* Entries queued before owners existed (DB v2) have no ownerUserId, so
|
||||
* clearQueueOf(userId) never reaches them - yet they are sent under whoever
|
||||
* signs in next. The logout dialog counts them with the person's own entries
|
||||
* (summarizeQueue treats them as own) and a discarding logout deletes them,
|
||||
* so they cannot end up in the next person's account.
|
||||
*/
|
||||
|
||||
type QueuedEntry = { id: number; ownerUserId?: string };
|
||||
|
||||
/** Every store that holds queued entries (all pending-* stores except photos). */
|
||||
async function queueStores(): Promise<QueueStoreName[]> {
|
||||
const db = await getDb();
|
||||
return [...db.objectStoreNames].filter(
|
||||
(name): name is QueueStoreName => name.startsWith("pending-") && name !== "pending-photos",
|
||||
);
|
||||
}
|
||||
|
||||
async function ownerlessEntries(store: QueueStoreName): Promise<QueuedEntry[]> {
|
||||
const db = await getDb();
|
||||
const entries = (await db.getAll(store)) as QueuedEntry[];
|
||||
return entries.filter((entry) => entry.ownerUserId === undefined);
|
||||
}
|
||||
|
||||
export async function clearOwnerlessEntries(): Promise<void> {
|
||||
const db = await getDb();
|
||||
for (const store of await queueStores()) {
|
||||
for (const entry of await ownerlessEntries(store)) {
|
||||
await db.delete(store, entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthUser } from "./auth-context";
|
||||
|
||||
type IdentityModule = typeof import("./offline-identity");
|
||||
type DbModule = typeof import("@/lib/offline/db");
|
||||
|
||||
let identity: IdentityModule;
|
||||
let db: DbModule;
|
||||
|
||||
const USER: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "[email protected]",
|
||||
name: "Test Imker",
|
||||
roleKey: "admin",
|
||||
roleName: "Admin",
|
||||
permissions: ["colonies.read"],
|
||||
mfaEnabled: true,
|
||||
hasAvatar: false,
|
||||
};
|
||||
|
||||
// Compile-time list: a new AuthUser field must be decided on here.
|
||||
const AUTH_USER_KEYS: Record<keyof AuthUser, true> = {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
roleKey: true,
|
||||
roleName: true,
|
||||
permissions: true,
|
||||
mfaEnabled: true,
|
||||
hasAvatar: true,
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const NOW = Date.parse("2026-10-14T08:00:00.000Z");
|
||||
|
||||
describe("offline identity", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
window.localStorage.clear();
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/offline/db");
|
||||
identity = await import("./offline-identity");
|
||||
});
|
||||
|
||||
it("stores exactly the user fields, never a token", async () => {
|
||||
const withToken = { ...USER, accessToken: "secret-token", refreshToken: "secret-refresh" };
|
||||
|
||||
await identity.saveIdentity(withToken as AuthUser, NOW);
|
||||
|
||||
const stored = (await (await db.getDb()).get("meta", "identity")) as {
|
||||
user: Record<string, unknown>;
|
||||
cachedAt: number;
|
||||
};
|
||||
expect(Object.keys(stored.user).sort()).toEqual(Object.keys(AUTH_USER_KEYS).sort());
|
||||
expect(JSON.stringify(stored)).not.toContain("secret");
|
||||
expect(stored.cachedAt).toBe(NOW);
|
||||
expect(await identity.loadIdentity(NOW)).toEqual(USER);
|
||||
});
|
||||
|
||||
it("expires after 7 days", async () => {
|
||||
await identity.saveIdentity(USER, NOW);
|
||||
|
||||
expect(await identity.loadIdentity(NOW + 7 * DAY_MS)).toEqual(USER);
|
||||
expect(await identity.loadIdentity(NOW + 7 * DAY_MS + 1)).toBeNull();
|
||||
// Gone for good, not only hidden.
|
||||
expect(await (await db.getDb()).get("meta", "identity")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null without an identity and after it was cleared", async () => {
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
await identity.saveIdentity(USER, NOW);
|
||||
await identity.clearIdentity();
|
||||
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("discards a stored value that is not a complete user", async () => {
|
||||
await (await db.getDb()).put("meta", { user: { id: "user-1" }, cachedAt: NOW }, "identity");
|
||||
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers which user was signed in last, apart from the identity", async () => {
|
||||
expect(await identity.loadLastUserId()).toBeNull();
|
||||
await identity.saveLastUserId("user-1");
|
||||
await identity.clearIdentity();
|
||||
|
||||
expect(await identity.loadLastUserId()).toBe("user-1");
|
||||
|
||||
await identity.clearLastUserId();
|
||||
expect(await identity.loadLastUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it("notes a pending logout on this device until it is cleared", () => {
|
||||
expect(identity.isLogoutPending()).toBe(false);
|
||||
|
||||
identity.markLogoutPending();
|
||||
expect(identity.isLogoutPending()).toBe(true);
|
||||
expect(window.localStorage.getItem("apicore:logout-pending")).not.toBeNull();
|
||||
|
||||
identity.clearLogoutPending();
|
||||
expect(identity.isLogoutPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import type { AuthUser } from "./auth-context";
|
||||
|
||||
/*
|
||||
* Who was signed in, so the app can open without network (offline session).
|
||||
* Only the profile fields live here - no access token, no refresh token: they
|
||||
* stay in memory and in the httpOnly cookie. An offline session therefore can
|
||||
* show cached data and queue input, but it cannot talk to the server until a
|
||||
* refresh succeeds again.
|
||||
*/
|
||||
|
||||
/** As long as the refresh session itself lives (SESSION_MAX_AGE of the backend). */
|
||||
export const OFFLINE_IDENTITY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const IDENTITY_KEY = "identity";
|
||||
const LAST_USER_KEY = "lastUserId";
|
||||
const LOGOUT_PENDING_KEY = "apicore:logout-pending";
|
||||
|
||||
interface StoredIdentity {
|
||||
user: AuthUser;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/** Copies the known fields only - whatever else the object carries is dropped. */
|
||||
function pickUser(user: AuthUser): AuthUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
roleKey: user.roleKey,
|
||||
roleName: user.roleName,
|
||||
permissions: [...user.permissions],
|
||||
mfaEnabled: user.mfaEnabled,
|
||||
hasAvatar: user.hasAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
function isUser(value: unknown): value is AuthUser {
|
||||
const user = value as Partial<AuthUser> | null;
|
||||
return (
|
||||
typeof user?.id === "string" &&
|
||||
typeof user.email === "string" &&
|
||||
typeof user.name === "string" &&
|
||||
typeof user.roleKey === "string" &&
|
||||
typeof user.roleName === "string" &&
|
||||
Array.isArray(user.permissions) &&
|
||||
user.permissions.every((permission) => typeof permission === "string") &&
|
||||
typeof user.mfaEnabled === "boolean" &&
|
||||
typeof user.hasAvatar === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function isStoredIdentity(value: unknown): value is StoredIdentity {
|
||||
const stored = value as Partial<StoredIdentity> | null;
|
||||
return typeof stored?.cachedAt === "number" && isUser(stored.user);
|
||||
}
|
||||
|
||||
export async function saveIdentity(user: AuthUser, now: number = Date.now()): Promise<void> {
|
||||
const db = await getDb();
|
||||
const stored: StoredIdentity = { user: pickUser(user), cachedAt: now };
|
||||
await db.put("meta", stored, IDENTITY_KEY);
|
||||
}
|
||||
|
||||
/** The cached user, or null when there is none, it is incomplete or older than 7 days. */
|
||||
export async function loadIdentity(now: number = Date.now()): Promise<AuthUser | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("meta", IDENTITY_KEY);
|
||||
if (isStoredIdentity(stored) && now - stored.cachedAt <= OFFLINE_IDENTITY_MAX_AGE_MS) {
|
||||
return pickUser(stored.user);
|
||||
}
|
||||
if (stored !== undefined) {
|
||||
await db.delete("meta", IDENTITY_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function clearIdentity(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("meta", IDENTITY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user who was signed in last - kept when the session just expires, so
|
||||
* the next sign-in of another account is recognised as a user switch.
|
||||
*/
|
||||
export async function loadLastUserId(): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("meta", LAST_USER_KEY);
|
||||
return typeof stored === "string" ? stored : null;
|
||||
}
|
||||
|
||||
export async function saveLastUserId(userId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put("meta", userId, LAST_USER_KEY);
|
||||
}
|
||||
|
||||
export async function clearLastUserId(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("meta", LAST_USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* A logout without network could not revoke the session on the server. This
|
||||
* note (no secret in it) makes the next start or sign-in do that first.
|
||||
* localStorage on purpose: it must be readable synchronously and survive a
|
||||
* failing IndexedDB.
|
||||
*/
|
||||
export function markLogoutPending(): void {
|
||||
try {
|
||||
window.localStorage.setItem(LOGOUT_PENDING_KEY, "1");
|
||||
} catch {
|
||||
// Storage unavailable: the cookie then expires on its own.
|
||||
}
|
||||
}
|
||||
|
||||
export function isLogoutPending(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(LOGOUT_PENDING_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLogoutPending(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(LOGOUT_PENDING_KEY);
|
||||
} catch {
|
||||
// Storage unavailable - nothing stored either.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import { clearQueueOf } from "@/lib/offline/db";
|
||||
import { forgetHandOversExcept } from "@/lib/photos/handover";
|
||||
import { discardDraftPhotosExcept } from "@/lib/photos/store";
|
||||
import { clearAllRounds } from "@/lib/rounds/round-state";
|
||||
import { clearOwnerlessEntries } from "./legacy-queue";
|
||||
import {
|
||||
removeOtherUsersData,
|
||||
restoreOfflineShell,
|
||||
wipeCacheStorage,
|
||||
wipeDeviceData,
|
||||
} from "./session-cleanup";
|
||||
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ deleteAllPacks: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock("@/lib/forms/draft-storage", () => ({ clearAllFormDrafts: vi.fn() }));
|
||||
vi.mock("@/lib/offline/db", () => ({ clearQueueOf: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock("@/lib/photos/handover", () => ({ forgetHandOversExcept: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
discardDraftPhotosExcept: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rounds/round-state", () => ({ clearAllRounds: vi.fn() }));
|
||||
vi.mock("./legacy-queue", () => ({ clearOwnerlessEntries: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
let cacheKeys: string[];
|
||||
let deleteCache: ReturnType<typeof vi.fn>;
|
||||
let postMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
function stubServiceWorker(replyWith: "done" | "silence"): void {
|
||||
postMessage = vi.fn((_message: unknown, [port]: MessagePort[]) => {
|
||||
if (replyWith === "done") {
|
||||
port.postMessage({ type: "WIPE_CACHES_DONE" });
|
||||
}
|
||||
});
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("session cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cacheKeys = ["apicore-shell-v4", "apicore-data-v2"];
|
||||
deleteCache = vi.fn().mockResolvedValue(true);
|
||||
vi.stubGlobal("caches", { keys: async () => cacheKeys, delete: deleteCache });
|
||||
stubServiceWorker("done");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("wipeCacheStorage", () => {
|
||||
it("tells the service worker first, then deletes every cache", async () => {
|
||||
await wipeCacheStorage();
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "WIPE_CACHES" }, expect.any(Array));
|
||||
expect(deleteCache).toHaveBeenCalledWith("apicore-shell-v4");
|
||||
expect(deleteCache).toHaveBeenCalledWith("apicore-data-v2");
|
||||
expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deleteCache.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not wait for a service worker that never answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
stubServiceWorker("silence");
|
||||
|
||||
const wiped = wipeCacheStorage();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await wiped;
|
||||
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("still deletes the caches without a service worker", async () => {
|
||||
vi.stubGlobal("navigator", { ...navigator, serviceWorker: undefined });
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await wipeCacheStorage();
|
||||
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wipeDeviceData", () => {
|
||||
it("removes what the person left on the device and keeps their queue by default", async () => {
|
||||
const ok = await wipeDeviceData({ keepDraftsOf: null });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(clearAllFormDrafts).toHaveBeenCalled();
|
||||
expect(clearAllRounds).toHaveBeenCalled();
|
||||
expect(deleteAllPacks).toHaveBeenCalled();
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalledWith(null);
|
||||
expect(forgetHandOversExcept).toHaveBeenCalledWith(null);
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
expect(clearQueueOf).not.toHaveBeenCalled();
|
||||
expect(clearOwnerlessEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also deletes the queue of the given account and every owner-less entry on request", async () => {
|
||||
await wipeDeviceData({ keepDraftsOf: null, discardQueueOf: "user-1" });
|
||||
|
||||
expect(clearQueueOf).toHaveBeenCalledWith("user-1");
|
||||
expect(clearOwnerlessEntries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes owner-less legacy entries on a user switch without touching anyone's queue", async () => {
|
||||
await wipeDeviceData({ keepDraftsOf: "user-2", discardOwnerless: true });
|
||||
|
||||
expect(clearOwnerlessEntries).toHaveBeenCalled();
|
||||
expect(clearQueueOf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps going when one step fails and reports it", async () => {
|
||||
vi.mocked(deleteAllPacks).mockRejectedValueOnce(new Error("IndexedDB blocked"));
|
||||
|
||||
const ok = await wipeDeviceData({ keepDraftsOf: null });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalled();
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeOtherUsersData", () => {
|
||||
it("drops the unsaved photos and hand-overs of every other account", async () => {
|
||||
await removeOtherUsersData("user-2");
|
||||
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalledWith("user-2");
|
||||
expect(forgetHandOversExcept).toHaveBeenCalledWith("user-2");
|
||||
expect(deleteAllPacks).not.toHaveBeenCalled();
|
||||
expect(deleteCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreOfflineShell", () => {
|
||||
it("lets the service worker see the runtime config again, then warms the shell", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("{}"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const warm = vi.fn();
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
serviceWorker: { getRegistration: async () => ({ active: { postMessage: warm } }) },
|
||||
});
|
||||
|
||||
await restoreOfflineShell();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/runtime-config", expect.anything());
|
||||
expect(warm).toHaveBeenCalledWith({ type: "WARM_SHELL" });
|
||||
expect(fetchMock.mock.invocationCallOrder[0]).toBeLessThan(warm.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("never throws, whatever is missing", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline")));
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await expect(restoreOfflineShell()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import { clearQueueOf } from "@/lib/offline/db";
|
||||
import { forgetHandOversExcept } from "@/lib/photos/handover";
|
||||
import { discardDraftPhotosExcept } from "@/lib/photos/store";
|
||||
import { clearAllRounds } from "@/lib/rounds/round-state";
|
||||
import { clearOwnerlessEntries } from "./legacy-queue";
|
||||
|
||||
/*
|
||||
* What a person leaves on a shared phone besides their session: unfinished
|
||||
* forms, a round, offline packs, unsaved photos, hand-over notes and the
|
||||
* service worker's copies of business data. Signing out and switching users
|
||||
* remove it; the queue of unsent entries is only deleted when asked for.
|
||||
*/
|
||||
|
||||
// The worker answers within milliseconds; a hung one must not block a logout.
|
||||
const SW_WIPE_TIMEOUT_MS = 3_000;
|
||||
|
||||
/** Asks the active service worker to wipe its caches and waits for its answer. */
|
||||
async function wipeServiceWorkerCaches(): Promise<void> {
|
||||
if (typeof navigator === "undefined" || !navigator.serviceWorker) {
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
const worker = registration?.active;
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(resolve, SW_WIPE_TIMEOUT_MS);
|
||||
channel.port1.onmessage = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
worker.postMessage({ type: "WIPE_CACHES" }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes everything in Cache Storage. The worker goes first: it stops every
|
||||
* running request from writing into the cache afterwards (WIPE_CACHES).
|
||||
*/
|
||||
export async function wipeCacheStorage(): Promise<void> {
|
||||
await wipeServiceWorkerCaches().catch(() => undefined);
|
||||
if (typeof caches === "undefined") {
|
||||
return;
|
||||
}
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
|
||||
interface WipeOptions {
|
||||
/** Signed out: null. Switched to this user: their own unsaved photos stay. */
|
||||
keepDraftsOf: string | null;
|
||||
/** Also delete this account's queue (and the owner-less legacy entries). */
|
||||
discardQueueOf?: string;
|
||||
/**
|
||||
* Also delete the entries that belong to no account (queued before owners
|
||||
* existed): they are sent under whoever signs in, so a user switch must
|
||||
* not leave them for the new account.
|
||||
*/
|
||||
discardOwnerless?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the previous person's data from this device. Every step is tried
|
||||
* even when another failed; the result says whether all of them worked.
|
||||
*/
|
||||
export async function wipeDeviceData({
|
||||
keepDraftsOf,
|
||||
discardQueueOf,
|
||||
discardOwnerless = false,
|
||||
}: WipeOptions): Promise<boolean> {
|
||||
const steps: Array<() => void | Promise<void>> = [
|
||||
clearAllFormDrafts,
|
||||
clearAllRounds,
|
||||
deleteAllPacks,
|
||||
() => discardDraftPhotosExcept(keepDraftsOf),
|
||||
() => forgetHandOversExcept(keepDraftsOf),
|
||||
wipeCacheStorage,
|
||||
];
|
||||
if (discardQueueOf !== undefined) {
|
||||
steps.push(() => clearQueueOf(discardQueueOf));
|
||||
}
|
||||
if (discardQueueOf !== undefined || discardOwnerless) {
|
||||
steps.push(clearOwnerlessEntries);
|
||||
}
|
||||
const results = await Promise.allSettled(steps.map(async (step) => step()));
|
||||
return results.every((result) => result.status === "fulfilled");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same account signs in again: nothing of theirs is removed, but what
|
||||
* other accounts left unsaved (photos, hand-overs) is not carried over.
|
||||
*/
|
||||
export async function removeOtherUsersData(userId: string): Promise<void> {
|
||||
await discardDraftPhotosExcept(userId);
|
||||
forgetHandOversExcept(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* After the caches were wiped in this page's lifetime the service worker is
|
||||
* blind: the runtime config (where the backend is) never passes it again, and
|
||||
* the shell is gone. Fetching the config once teaches it the backend; then it
|
||||
* refills the shell. Best effort, never throws.
|
||||
*/
|
||||
export async function restoreOfflineShell(): Promise<void> {
|
||||
try {
|
||||
await fetch("/api/runtime-config", { cache: "no-store" });
|
||||
if (typeof navigator === "undefined" || !navigator.serviceWorker) {
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
registration?.active?.postMessage({ type: "WARM_SHELL" });
|
||||
} catch {
|
||||
// Offline or no worker: the next online visit refills it.
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,25 @@ export function getAccessToken(): string | null {
|
||||
export function setAccessToken(token: string | null): void {
|
||||
currentAccessToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether an access token that came out of a silent refresh (a 401 in
|
||||
* the api client) may be used. The refresh cookie is shared by every tab, so
|
||||
* it can belong to another account than the one this tab shows.
|
||||
*/
|
||||
export type RefreshedTokenHandler = (token: string) => Promise<boolean>;
|
||||
|
||||
let refreshedTokenHandler: RefreshedTokenHandler | null = null;
|
||||
|
||||
export function setRefreshedTokenHandler(handler: RefreshedTokenHandler | null): void {
|
||||
refreshedTokenHandler = handler;
|
||||
}
|
||||
|
||||
/** True when the token was taken into use and the failed request may be retried. */
|
||||
export async function acceptRefreshedToken(token: string): Promise<boolean> {
|
||||
if (!refreshedTokenHandler) {
|
||||
setAccessToken(token);
|
||||
return true;
|
||||
}
|
||||
return refreshedTokenHandler(token);
|
||||
}
|
||||
@@ -6,6 +6,14 @@ export interface BackupImportSummary {
|
||||
sealed: boolean;
|
||||
}
|
||||
|
||||
/** The newest run of the hourly app-backup job (CronJob). */
|
||||
export interface BackupJobRunStatus {
|
||||
status: "RUNNING" | "SUCCEEDED" | "FAILED";
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BackupScheduleStatus {
|
||||
enabled: boolean;
|
||||
path: string | null;
|
||||
@@ -23,6 +31,8 @@ export interface BackupScheduleStatus {
|
||||
cronSchedule: boolean;
|
||||
retentionCount: boolean;
|
||||
};
|
||||
/** Newest app-backup job run, null before the first one. lastRunAt is the newest backup file. */
|
||||
lastJobRun: BackupJobRunStatus | null;
|
||||
}
|
||||
|
||||
export interface BackupSettings {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import { invalidColonyIdsOfBody } from "./bulk-input";
|
||||
import type { CreateTreatmentInput, Treatment } from "@/lib/treatments/types";
|
||||
import type { CreateFeedingInput, Feeding } from "@/lib/feedings/types";
|
||||
import type { CreateVarroaMeasurementInput, VarroaMeasurement } from "@/lib/varroa/types";
|
||||
|
||||
/** Mirrors ApiCore-Backend/src/common/bulk/bulk-limits.ts - keep both in step. */
|
||||
export const BULK_MAX_COLONIES = 100;
|
||||
|
||||
export interface BulkCreateResult<T> {
|
||||
bulkId: string;
|
||||
records: T[];
|
||||
}
|
||||
|
||||
export interface BulkCreateTreatmentInput extends Omit<CreateTreatmentInput, "colonyId"> {
|
||||
colonyIds: string[];
|
||||
}
|
||||
|
||||
export interface BulkCreateFeedingInput extends Omit<CreateFeedingInput, "colonyId"> {
|
||||
colonyIds: string[];
|
||||
}
|
||||
|
||||
/** What each colony has of its own in a bulk count. */
|
||||
export interface BulkVarroaEntry {
|
||||
colonyId: string;
|
||||
miteCount?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
assessment?: string | null;
|
||||
}
|
||||
|
||||
export interface BulkCreateVarroaInput
|
||||
extends Omit<
|
||||
CreateVarroaMeasurementInput,
|
||||
"colonyId" | "miteCount" | "calculatedValue" | "assessment"
|
||||
> {
|
||||
entries: BulkVarroaEntry[];
|
||||
}
|
||||
|
||||
function postBulk<Record>(
|
||||
path: string,
|
||||
input: unknown,
|
||||
options: CreateOptions,
|
||||
): Promise<BulkCreateResult<Record>> {
|
||||
return apiFetch<BulkCreateResult<Record>>(path, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createTreatmentsBulk(
|
||||
input: BulkCreateTreatmentInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<Treatment>> {
|
||||
return postBulk("/treatments/bulk", input, options);
|
||||
}
|
||||
|
||||
export function createFeedingsBulk(
|
||||
input: BulkCreateFeedingInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<Feeding>> {
|
||||
return postBulk("/feedings/bulk", input, options);
|
||||
}
|
||||
|
||||
export function createVarroaBulk(
|
||||
input: BulkCreateVarroaInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<VarroaMeasurement>> {
|
||||
return postBulk("/varroa-measurements/bulk", input, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colonies a refused bulk entry could not be recorded for (400
|
||||
* `invalidColonyIds`: unknown or ended since the pack was loaded). Empty for
|
||||
* every other error.
|
||||
*/
|
||||
export function invalidColonyIdsOf(error: unknown): string[] {
|
||||
return error instanceof ApiError ? invalidColonyIdsOfBody(error.body) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The two 409 texts of the idempotency interceptor that mean „later“. The
|
||||
* server sends no error code for them; api.spec.ts pins both to its source.
|
||||
*/
|
||||
export const RETRY_LATER_MESSAGES = [
|
||||
"Diese Anfrage wird bereits verarbeitet - bitte kurz warten",
|
||||
"Diese Anfrage wurde gerade abgebrochen - bitte erneut senden",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 409 „wird bereits verarbeitet“ (the same key is still running) and „gerade
|
||||
* abgebrochen“ (it was just released): nothing is wrong with the entry, the
|
||||
* answer is „später erneut“. Other 409s (key used for another action) are real.
|
||||
*/
|
||||
export function isRetryLaterError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.status === 409 &&
|
||||
RETRY_LATER_MESSAGES.some((message) => error.message === message)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colonyIdsOfBulkInput, withoutColonies } from "./bulk-input";
|
||||
|
||||
const TREATMENT = { date: "2026-10-14", product: "Oxuvar", colonyIds: ["a", "b", "c"] };
|
||||
const VARROA = {
|
||||
date: "2026-10-14",
|
||||
method: "NATURAL_MITE_FALL",
|
||||
entries: [
|
||||
{ colonyId: "a", miteCount: 4 },
|
||||
{ colonyId: "b", miteCount: 9 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("colonyIdsOfBulkInput", () => {
|
||||
it("lists the colonies of every kind", () => {
|
||||
expect(colonyIdsOfBulkInput("treatments", TREATMENT)).toEqual(["a", "b", "c"]);
|
||||
expect(colonyIdsOfBulkInput("feedings", TREATMENT)).toEqual(["a", "b", "c"]);
|
||||
expect(colonyIdsOfBulkInput("varroa", VARROA)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("is empty for input of an unknown shape", () => {
|
||||
expect(colonyIdsOfBulkInput("treatments", null)).toEqual([]);
|
||||
expect(colonyIdsOfBulkInput("varroa", { entries: "x" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutColonies", () => {
|
||||
it("drops the colonies and keeps every shared field", () => {
|
||||
expect(withoutColonies("treatments", TREATMENT, ["b"])).toEqual({
|
||||
...TREATMENT,
|
||||
colonyIds: ["a", "c"],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a varroa colony together with its own count", () => {
|
||||
expect(withoutColonies("varroa", VARROA, ["a"])).toEqual({
|
||||
...VARROA,
|
||||
entries: [{ colonyId: "b", miteCount: 9 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not change the input", () => {
|
||||
const before = structuredClone(TREATMENT);
|
||||
withoutColonies("treatments", TREATMENT, ["a"]);
|
||||
expect(TREATMENT).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BulkKind } from "@/lib/offline/db";
|
||||
|
||||
function listOf(input: unknown, key: "colonyIds" | "entries"): unknown[] {
|
||||
const list = (input as Record<string, unknown> | null)?.[key];
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** The colonies a queued bulk entry is for (varroa: one entry per colony). */
|
||||
export function colonyIdsOfBulkInput(kind: BulkKind, input: unknown): string[] {
|
||||
if (kind === "varroa") {
|
||||
return listOf(input, "entries").flatMap((entry) => {
|
||||
const colonyId = (entry as { colonyId?: unknown } | null)?.colonyId;
|
||||
return typeof colonyId === "string" ? [colonyId] : [];
|
||||
});
|
||||
}
|
||||
return listOf(input, "colonyIds").filter((id): id is string => typeof id === "string");
|
||||
}
|
||||
|
||||
/** The input without the given colonies; every shared field stays as entered. */
|
||||
export function withoutColonies(
|
||||
kind: BulkKind,
|
||||
input: unknown,
|
||||
colonyIds: readonly string[],
|
||||
): unknown {
|
||||
if (kind === "varroa") {
|
||||
return {
|
||||
...(input as object),
|
||||
entries: listOf(input, "entries").filter(
|
||||
(entry) => !colonyIds.includes((entry as { colonyId: string }).colonyId),
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(input as object),
|
||||
colonyIds: listOf(input, "colonyIds").filter((id) => !colonyIds.includes(id as string)),
|
||||
};
|
||||
}
|
||||
|
||||
/** `invalidColonyIds` of a server answer body (400 of a bulk endpoint); empty otherwise. */
|
||||
export function invalidColonyIdsOfBody(body: unknown): string[] {
|
||||
const ids = (body as { invalidColonyIds?: unknown } | null)?.invalidColonyIds;
|
||||
return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bulkDestination,
|
||||
bulkQueuedText,
|
||||
bulkSavedText,
|
||||
bulkSelectionError,
|
||||
} from "./capture-texts";
|
||||
|
||||
describe("bulkSelectionError", () => {
|
||||
it("asks for a stand first, then for at least one colony", () => {
|
||||
expect(bulkSelectionError(null, [])).toBe("Bitte einen Stand wählen.");
|
||||
expect(bulkSelectionError("apiary-1", [])).toBe("Bitte mindestens ein Volk wählen.");
|
||||
});
|
||||
|
||||
it("allows 100 colonies and names the limit for 101", () => {
|
||||
const ids = (count: number) => Array.from({ length: count }, (_, index) => `c-${index}`);
|
||||
|
||||
expect(bulkSelectionError("apiary-1", ids(100))).toBeUndefined();
|
||||
expect(bulkSelectionError("apiary-1", ids(101))).toBe("Höchstens 100 Völker auf einmal.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk texts", () => {
|
||||
it("says how many colonies were saved, in the singular too", () => {
|
||||
expect(bulkSavedText("Behandlung", 3)).toBe("Behandlung für 3 Völker gespeichert");
|
||||
expect(bulkSavedText("Fütterung", 1)).toBe("Fütterung für 1 Volk gespeichert");
|
||||
});
|
||||
|
||||
it("says that an entry waits offline", () => {
|
||||
expect(bulkQueuedText("Varroamessung", 4)).toBe(
|
||||
"Kein Netzwerk – Varroamessung für 4 Völker wurde offline gespeichert und wird synchronisiert.",
|
||||
);
|
||||
});
|
||||
|
||||
it("goes back to the apiary, or to the overview without one", () => {
|
||||
expect(bulkDestination("apiary-1")).toBe("/apiaries/apiary-1");
|
||||
expect(bulkDestination(null)).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BULK_MAX_COLONIES } from "./api";
|
||||
|
||||
/** What is wrong with the colonies chosen for a bulk capture, if anything. */
|
||||
export function bulkSelectionError(
|
||||
apiaryId: string | null,
|
||||
colonyIds: readonly string[],
|
||||
): string | undefined {
|
||||
if (!apiaryId) return "Bitte einen Stand wählen.";
|
||||
if (colonyIds.length === 0) return "Bitte mindestens ein Volk wählen.";
|
||||
if (colonyIds.length > BULK_MAX_COLONIES) {
|
||||
return `Höchstens ${BULK_MAX_COLONIES} Völker auf einmal.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function colonies(count: number): string {
|
||||
return count === 1 ? "1 Volk" : `${count} Völker`;
|
||||
}
|
||||
|
||||
/** „Behandlung für 3 Völker gespeichert“ - `kind` is the German name of the record. */
|
||||
export function bulkSavedText(kind: string, count: number): string {
|
||||
return `${kind} für ${colonies(count)} gespeichert`;
|
||||
}
|
||||
|
||||
export function bulkQueuedText(kind: string, count: number): string {
|
||||
return `Kein Netzwerk – ${kind} für ${colonies(count)} wurde offline gespeichert und wird synchronisiert.`;
|
||||
}
|
||||
|
||||
/** Where a saved bulk capture goes next: its stand, or the overview. */
|
||||
export function bulkDestination(apiaryId: string | null): string {
|
||||
return apiaryId ? `/apiaries/${apiaryId}` : "/dashboard";
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { livingColoniesFromPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
|
||||
/** What a bulk choice needs of a colony - a server colony or one from an offline pack. */
|
||||
export type BulkColony = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
|
||||
export interface BulkApiary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Where the choices came from: the server, or - only without network - the loaded packs. */
|
||||
export type ChoiceSource = "server" | "packs";
|
||||
|
||||
interface Choices<T> {
|
||||
items: T[];
|
||||
source: ChoiceSource;
|
||||
}
|
||||
|
||||
/** Prefix of the queries here; they read the server or this device's storage, never both as one. */
|
||||
const BULK_CHOICES_KEY = ["bulk-choices"] as const;
|
||||
|
||||
function byCode(a: BulkColony, b: BulkColony): number {
|
||||
return a.code.localeCompare(b.code, "de", { numeric: true });
|
||||
}
|
||||
|
||||
function livingOf(colonies: readonly BulkColony[]): BulkColony[] {
|
||||
return colonies
|
||||
.filter(isLivingColony)
|
||||
.map(({ id, code, status, apiaryId }) => ({ id, code, status, apiaryId }))
|
||||
.sort(byCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* The apiaries to choose from: the server's active ones, or - only when the
|
||||
* network failed - those of the user's offline packs. Names only: nothing
|
||||
* else of an apiary (location!) is needed or kept.
|
||||
*/
|
||||
export function useApiaryChoices() {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: [...BULK_CHOICES_KEY, userId, "apiaries"],
|
||||
queryFn: (): Promise<Choices<BulkApiary>> =>
|
||||
withPackFallback<Choices<BulkApiary>>(
|
||||
async () => ({
|
||||
source: "server",
|
||||
items: (await fetchApiaries({ status: "ACTIVE" })).map(({ id, name }) => ({ id, name })),
|
||||
}),
|
||||
(packs) =>
|
||||
packs.length === 0
|
||||
? null
|
||||
: {
|
||||
source: "packs",
|
||||
items: packs
|
||||
.map(({ pack }) => ({ id: pack.apiary.id, name: pack.apiary.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||
},
|
||||
userId ?? "",
|
||||
),
|
||||
// Reads this device's storage when offline, never waits for the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The living colonies of one apiary - ended ones never appear. Without
|
||||
* network the apiary's offline pack answers; with no pack for it the
|
||||
* request fails and the caller says so.
|
||||
*/
|
||||
export function useApiaryColonies(apiaryId: string | null) {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: [...BULK_CHOICES_KEY, userId, "colonies", apiaryId],
|
||||
queryFn: (): Promise<Choices<BulkColony>> =>
|
||||
withPackFallback<Choices<BulkColony>>(
|
||||
async () => ({
|
||||
source: "server",
|
||||
items: livingOf(await fetchColonies({ apiaryId: apiaryId ?? undefined })),
|
||||
}),
|
||||
(packs) =>
|
||||
packs.some((stored) => stored.apiaryId === apiaryId)
|
||||
? {
|
||||
source: "packs",
|
||||
items: livingOf(
|
||||
livingColoniesFromPacks(packs).filter((colony) => colony.apiaryId === apiaryId),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
userId ?? "",
|
||||
),
|
||||
enabled: apiaryId !== null && apiaryId !== "",
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { BulkKind, queueBulk } from "@/lib/offline/db";
|
||||
import { CreateOptions } from "@/lib/offline/idempotency";
|
||||
import {
|
||||
CaptureMutation,
|
||||
CaptureOutcome,
|
||||
useCaptureMutation,
|
||||
} from "@/lib/offline/use-capture-mutation";
|
||||
import { invalidColonyIdsOf, isRetryLaterError } from "./api";
|
||||
|
||||
interface BulkCaptureOptions<I, R> {
|
||||
kind: BulkKind;
|
||||
create: (input: I, options: CreateOptions) => Promise<R>;
|
||||
failureMessage: string;
|
||||
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BulkCapture<I> extends CaptureMutation<I> {
|
||||
/** Colonies the server refused (400): unknown or ended since the list was loaded. */
|
||||
invalidColonyIds: string[];
|
||||
/** 409 „wird bereits verarbeitet“: not an error, just save again in a moment. */
|
||||
isRetryLater: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a capture for several colonies: online as one request, offline as one
|
||||
* queued bulk entry. Every distinct selection gets its own idempotency key
|
||||
* (the server binds a key to the user and action, not to what was sent). A
|
||||
* refusal naming colonies reloads the packs of those colonies at once, like a
|
||||
* rejected queued entry does.
|
||||
*/
|
||||
export function useBulkCapture<I, R>({
|
||||
kind,
|
||||
create,
|
||||
failureMessage,
|
||||
onSaved,
|
||||
}: BulkCaptureOptions<I, R>): BulkCapture<I> {
|
||||
const userId = useCurrentUserId();
|
||||
const capture = useCaptureMutation<I, R>({
|
||||
create,
|
||||
queue: (input, idempotencyKey, ownerUserId) =>
|
||||
queueBulk(kind, input, idempotencyKey, ownerUserId),
|
||||
failureMessage,
|
||||
onSaved,
|
||||
keyPerSubmission: true,
|
||||
});
|
||||
const invalidColonyIds = invalidColonyIdsOf(capture.error);
|
||||
const invalidKey = invalidColonyIds.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && invalidKey) {
|
||||
void refreshPacksAfterSync(userId, Date.now(), undefined, {
|
||||
force: true,
|
||||
colonyIds: invalidKey.split(","),
|
||||
});
|
||||
}
|
||||
}, [userId, invalidKey]);
|
||||
|
||||
return { ...capture, invalidColonyIds, isRetryLater: isRetryLaterError(capture.error) };
|
||||
}
|
||||
@@ -1,9 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { DRAFT_PREFIX, clearAllFormDrafts } from "./draft-storage";
|
||||
import { DRAFT_MAX_AGE_MS, DRAFT_PREFIX, clearAllFormDrafts, readLiveFormDrafts } from "./draft-storage";
|
||||
import { useFormDraft } from "./use-form-draft";
|
||||
|
||||
describe("form drafts", () => {
|
||||
it("reads the values of every draft that can still be restored", () => {
|
||||
window.localStorage.clear();
|
||||
const now = Date.parse("2026-10-13T08:00:00.000Z");
|
||||
const store = (key: string, savedAt: number, values: unknown) =>
|
||||
window.localStorage.setItem(`${DRAFT_PREFIX}${key}`, JSON.stringify({ savedAt, values }));
|
||||
store("user-1:inspection-new", now - DRAFT_MAX_AGE_MS, { photoSessionId: "restorable" });
|
||||
store("user-2:inspection-new", now - 60_000, { photoSessionId: "other-user" });
|
||||
store("user-1:feeding-new", now - DRAFT_MAX_AGE_MS - 1, { photoSessionId: "expired" });
|
||||
window.localStorage.setItem(`${DRAFT_PREFIX}user-1:harvest-new`, "{not json");
|
||||
window.localStorage.setItem("apicore:motion", JSON.stringify({ savedAt: now, values: {} }));
|
||||
|
||||
const live = readLiveFormDrafts(now);
|
||||
|
||||
expect(live).toHaveLength(2);
|
||||
expect(live).toEqual(
|
||||
expect.arrayContaining([{ photoSessionId: "restorable" }, { photoSessionId: "other-user" }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes every draft on logout and nothing else", () => {
|
||||
window.localStorage.setItem(`${DRAFT_PREFIX}user-1:inspection-new`, "{}");
|
||||
window.localStorage.setItem("apicore:motion", "subtle");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const DRAFT_PREFIX = "apicore:draft:";
|
||||
|
||||
/** Drafts older than this are stale (the situation at the hive has changed). */
|
||||
export const DRAFT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Removes every draft on this device - called on logout, so the next
|
||||
* person on a shared phone never sees someone else's unfinished entry.
|
||||
@@ -16,3 +19,33 @@ export function clearAllFormDrafts(): void {
|
||||
// Storage unavailable - nothing stored either.
|
||||
}
|
||||
}
|
||||
|
||||
function liveValues(raw: string | null, now: number): unknown[] {
|
||||
try {
|
||||
const draft = JSON.parse(raw ?? "null") as { savedAt?: unknown; values?: unknown } | null;
|
||||
const isLive = typeof draft?.savedAt === "number" && now - draft.savedAt <= DRAFT_MAX_AGE_MS;
|
||||
return isLive ? [draft.values] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The values of every draft on this device that a form would still offer
|
||||
* (of any user and form) - so nothing a draft refers to is cleaned up early.
|
||||
*/
|
||||
export function readLiveFormDrafts(now: number): unknown[] {
|
||||
try {
|
||||
const drafts: unknown[] = [];
|
||||
for (let index = 0; index < window.localStorage.length; index++) {
|
||||
const storageKey = window.localStorage.key(index);
|
||||
if (storageKey?.startsWith(DRAFT_PREFIX)) {
|
||||
drafts.push(...liveValues(window.localStorage.getItem(storageKey), now));
|
||||
}
|
||||
}
|
||||
return drafts;
|
||||
} catch {
|
||||
// Storage unavailable - no draft can be offered either.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { DRAFT_PREFIX } from "./draft-storage";
|
||||
import { useFormDraft } from "./use-form-draft";
|
||||
@@ -10,6 +10,35 @@ describe("useFormDraft", () => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("clear() also drops the write still waiting from the last change", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result, rerender } = renderHook(
|
||||
({ values }) => useFormDraft("test-form", values, true),
|
||||
{ initialProps: { values: { a: "x" } } },
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
const changed = { a: "xy" };
|
||||
rerender({ values: changed });
|
||||
|
||||
// Saved less than 600 ms after the last change; the form stays mounted
|
||||
// (a stand round shows its next-colony panel).
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
act(() => result.current.clear());
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => result.current.flush());
|
||||
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull();
|
||||
|
||||
// A real change after that is kept again.
|
||||
rerender({ values: { a: "new entry" } });
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!).values).toEqual({ a: "new entry" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("flush writes immediately", () => {
|
||||
const { result } = renderHook(() => useFormDraft("test-form", { a: "x" }, true));
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
|
||||
import { DRAFT_PREFIX as PREFIX } from "./draft-storage";
|
||||
import { DRAFT_MAX_AGE_MS as MAX_AGE_MS, DRAFT_PREFIX as PREFIX } from "./draft-storage";
|
||||
const SAVE_DELAY_MS = 600;
|
||||
// Drafts older than this are stale (the situation at the hive has changed).
|
||||
const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface StoredDraft<T> {
|
||||
savedAt: number;
|
||||
@@ -81,13 +79,20 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
// The entry a save has just cleared the draft for: never written back, even
|
||||
// though the form may stay mounted (a stand round shows its next panel).
|
||||
// The next change writes again.
|
||||
const cleared = useRef<{ values: T } | null>(null);
|
||||
const pendingWrite = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty || pending) return;
|
||||
if (!dirty || pending || cleared.current?.values === values) return;
|
||||
const timer = window.setTimeout(() => write(key, values), SAVE_DELAY_MS);
|
||||
pendingWrite.current = timer;
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [key, values, dirty, pending]);
|
||||
|
||||
// The latest state for `flush`, which must stay a stable callback.
|
||||
// The latest state for `flush` and `clear`, which must stay stable callbacks.
|
||||
const latest = useRef({ key, values, dirty, pending });
|
||||
useEffect(() => {
|
||||
latest.current = { key, values, dirty, pending };
|
||||
@@ -96,7 +101,7 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
|
||||
// An offered earlier draft is not overwritten: it is still the user's choice.
|
||||
const flush = useCallback(() => {
|
||||
const current = latest.current;
|
||||
if (!current.dirty || current.pending) return;
|
||||
if (!current.dirty || current.pending || cleared.current?.values === current.values) return;
|
||||
write(current.key, current.values);
|
||||
}, []);
|
||||
|
||||
@@ -111,7 +116,12 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
|
||||
setPending(null);
|
||||
}, [key]);
|
||||
|
||||
const clear = useCallback(() => remove(key), [key]);
|
||||
const clear = useCallback(() => {
|
||||
// A write still waiting from the last change would bring the draft back.
|
||||
window.clearTimeout(pendingWrite.current);
|
||||
cleared.current = { values: latest.current.values };
|
||||
remove(key);
|
||||
}, [key]);
|
||||
|
||||
return { pending, restore, discard, clear, flush };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
fetchReportingOutcome,
|
||||
fetchWithTimeout,
|
||||
isNetworkError,
|
||||
BULK_WRITE_TIMEOUT_MS,
|
||||
timeoutFor,
|
||||
} from "./fetch-with-timeout";
|
||||
|
||||
@@ -36,6 +37,32 @@ describe("fetch-with-timeout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("a bulk POST waits as long as the server transaction may take, other keyed writes 4 s", async () => {
|
||||
const fetchMock = vi.fn(hangingFetch);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let bulkError: unknown = null;
|
||||
let singleError: unknown = null;
|
||||
fetchReportingOutcome("https://api.example/treatments/bulk", {
|
||||
method: "POST",
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}).catch((error: unknown) => {
|
||||
bulkError = error;
|
||||
});
|
||||
fetchReportingOutcome("https://api.example/treatments", {
|
||||
method: "POST",
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}).catch((error: unknown) => {
|
||||
singleError = error;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMEOUT_MS + 100);
|
||||
expect(singleError).toMatchObject({ name: "TimeoutError" });
|
||||
expect(bulkError).toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(BULK_WRITE_TIMEOUT_MS);
|
||||
expect(bulkError).toMatchObject({ name: "TimeoutError" });
|
||||
});
|
||||
|
||||
it("rejects with TimeoutError after the timeout", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(hangingFetch));
|
||||
const pending = fetchWithTimeout("/x", { timeoutMs: 4_000 });
|
||||
|
||||
@@ -2,6 +2,8 @@ import { reportRequestOutcome } from "../offline/connectivity";
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 4_000; // GET and writes with Idempotency-Key
|
||||
export const NON_IDEMPOTENT_WRITE_TIMEOUT_MS = 15_000;
|
||||
/** A bulk POST writes up to 100 records in one server transaction (15 s allowed there). */
|
||||
export const BULK_WRITE_TIMEOUT_MS = 15_000;
|
||||
export const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
export const DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
|
||||
@@ -12,6 +14,12 @@ export function timeoutFor(method: string, headers: HeadersInit | undefined): nu
|
||||
return NON_IDEMPOTENT_WRITE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function isBulkPost(input: RequestInfo | URL, method: string | undefined): boolean {
|
||||
if ((method ?? "GET").toUpperCase() !== "POST") return false;
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return /\/bulk(?:[?#]|$)/.test(url);
|
||||
}
|
||||
|
||||
// Statuses that must not carry a body when a Response is rebuilt.
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
@@ -86,8 +94,11 @@ export async function fetchReportingOutcome(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit & { timeoutMs: number },
|
||||
): Promise<Response> {
|
||||
const timeoutMs = isBulkPost(input, init.method)
|
||||
? Math.max(init.timeoutMs, BULK_WRITE_TIMEOUT_MS)
|
||||
: init.timeoutMs;
|
||||
try {
|
||||
const response = await fetchWithTimeout(input, init);
|
||||
const response = await fetchWithTimeout(input, { ...init, timeoutMs });
|
||||
reportRequestOutcome("reached-server");
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,7 +42,10 @@ function textOrNull(text: string | null): string | null {
|
||||
* What the last inspection recorded for a field, in the form's own shape;
|
||||
* `null` when it was not recorded then (nothing to offer or take over).
|
||||
*/
|
||||
export function previousValueOf(last: Inspection, field: TakeoverField): TakeoverValue | null {
|
||||
export function previousValueOf(
|
||||
last: Omit<Inspection, "userId">,
|
||||
field: TakeoverField,
|
||||
): TakeoverValue | null {
|
||||
switch (field) {
|
||||
case "broodFrames":
|
||||
return countText(last.broodFrames);
|
||||
@@ -106,7 +109,7 @@ function withField(
|
||||
*/
|
||||
export function takeOverAll(
|
||||
values: InspectionFormValues,
|
||||
last: Inspection,
|
||||
last: Omit<Inspection, "userId">,
|
||||
): { values: InspectionFormValues; taken: TakeoverField[] } {
|
||||
let next = values;
|
||||
const taken: TakeoverField[] = [];
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { fetchColonyInspections } from "./api";
|
||||
import { useLastInspection } from "./use-last-inspection";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import {
|
||||
offlinePack,
|
||||
packColony,
|
||||
packInspection,
|
||||
storedPack,
|
||||
} from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import type { Inspection } from "./types";
|
||||
|
||||
vi.mock("./api", () => ({ fetchColonyInspections: vi.fn() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function day(isoDay: string): string {
|
||||
return `${isoDay}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
function renderLastInspection(client: QueryClient) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(() => useLastInspection("colony-1"), { wrapper });
|
||||
}
|
||||
|
||||
function newClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
describe("useLastInspection", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonyInspections).mockReset();
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("takes the newest inspection the server has", async () => {
|
||||
vi.mocked(fetchColonyInspections).mockResolvedValue([
|
||||
{ ...packInspection("old", "colony-1", day("2026-09-01")), userId: "u" },
|
||||
{ ...packInspection("new", "colony-1", day("2026-10-01")), userId: "u" },
|
||||
]);
|
||||
|
||||
const { result } = renderLastInspection(newClient());
|
||||
|
||||
await waitFor(() => expect(result.current.inspection?.id).toBe("new"));
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the last inspection from the loaded pack while offline", async () => {
|
||||
vi.mocked(fetchColonyInspections).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(listPacks).mockResolvedValue([
|
||||
storedPack(
|
||||
offlinePack({
|
||||
colonies: [packColony("colony-1", "A1")],
|
||||
inspections: [
|
||||
packInspection("p-old", "colony-1", day("2026-09-20")),
|
||||
packInspection("p-new", "colony-1", day("2026-10-02")),
|
||||
packInspection("other", "colony-2", day("2026-10-05")),
|
||||
],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const { result } = renderLastInspection(newClient());
|
||||
|
||||
await waitFor(() => expect(result.current.inspection?.id).toBe("p-new"));
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the colony page's inspections at once while it asks the server again", async () => {
|
||||
vi.mocked(fetchColonyInspections).mockReturnValue(new Promise<Inspection[]>(() => undefined));
|
||||
const client = newClient();
|
||||
client.setQueryData<Inspection[]>(
|
||||
["colonies", "colony-1", "inspections"],
|
||||
[{ ...packInspection("cached", "colony-1", day("2026-10-01")), userId: "u" }],
|
||||
);
|
||||
|
||||
const { result } = renderLastInspection(client);
|
||||
|
||||
expect(result.current.inspection?.id).toBe("cached");
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { inspectionsFromPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
import { fetchColonyInspections } from "./api";
|
||||
import type { Inspection } from "./types";
|
||||
|
||||
function latestOf(inspections: readonly Inspection[]): Inspection | null {
|
||||
/** What the hints read: an inspection from the server or from an offline pack. */
|
||||
export type LastInspection = Omit<Inspection, "userId">;
|
||||
|
||||
function latestOf(inspections: readonly LastInspection[]): LastInspection | null {
|
||||
return (
|
||||
[...inspections].sort(
|
||||
(a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt),
|
||||
@@ -13,17 +19,35 @@ function latestOf(inspections: readonly Inspection[]): Inspection | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The colony's most recent inspection, for the "Letztes Mal" hints. Shares
|
||||
* the colony page's inspections query, so it is usually already cached.
|
||||
* The colony's most recent inspection, for the "Letztes Mal" hints. Without
|
||||
* network it comes from the colony's offline pack. The colony page's
|
||||
* inspections, when cached, show at once while the server is asked; pack
|
||||
* data never goes into that shared cache (it holds only the last three).
|
||||
*/
|
||||
export function useLastInspection(colonyId: string): {
|
||||
inspection: Inspection | null;
|
||||
inspection: LastInspection | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const userId = useCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
const colonyInspectionsKey = ["colonies", colonyId, "inspections"] as const;
|
||||
const query = useQuery({
|
||||
queryKey: ["colonies", colonyId, "inspections"],
|
||||
queryFn: () => fetchColonyInspections(colonyId),
|
||||
queryKey: [...colonyInspectionsKey, "latest", userId],
|
||||
queryFn: (): Promise<LastInspection[]> => {
|
||||
const fromServer = () => fetchColonyInspections(colonyId);
|
||||
if (!userId) {
|
||||
return fromServer();
|
||||
}
|
||||
return withPackFallback<LastInspection[]>(
|
||||
fromServer,
|
||||
(packs) => inspectionsFromPacks(packs, colonyId),
|
||||
userId,
|
||||
);
|
||||
},
|
||||
placeholderData: () => queryClient.getQueryData<Inspection[]>(colonyInspectionsKey),
|
||||
enabled: colonyId !== "",
|
||||
// Offline the pack answers from this device: never wait for a network.
|
||||
networkMode: "always",
|
||||
});
|
||||
return {
|
||||
inspection: query.data ? latestOf(query.data) : null,
|
||||
|
||||
@@ -227,3 +227,151 @@ describe("offline queue database", () => {
|
||||
raw.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk entries in the offline queue", () => {
|
||||
const BULK_TREATMENT = {
|
||||
date: "2026-10-14T00:00:00.000Z",
|
||||
product: "Oxuvar",
|
||||
batchNumber: "Ch-123",
|
||||
colonyIds: ["c-1", "c-2", "c-3"],
|
||||
};
|
||||
const BULK_VARROA = {
|
||||
date: "2026-10-14T00:00:00.000Z",
|
||||
method: "NATURAL_MITE_FALL",
|
||||
entries: [
|
||||
{ colonyId: "c-1", miteCount: 4 },
|
||||
{ colonyId: "c-3", miteCount: 9 },
|
||||
],
|
||||
};
|
||||
const INVALID_COLONIES = {
|
||||
status: 400,
|
||||
message: "Einige Völker sind nicht (mehr) erfassbar",
|
||||
details: { invalidColonyIds: ["c-3"] },
|
||||
at: "2026-10-14T09:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
async function onlyBulkEntry() {
|
||||
const raw = await openDB(DB_NAME);
|
||||
const [entry] = await raw.getAll("pending-bulk");
|
||||
raw.close();
|
||||
return entry;
|
||||
}
|
||||
|
||||
it("queueBulk stores exactly one entry with kind, input, key and owner", async () => {
|
||||
const db = await loadDb();
|
||||
|
||||
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
|
||||
expect(await onlyBulkEntry()).toEqual({
|
||||
id: 1,
|
||||
kind: "treatments",
|
||||
input: BULK_TREATMENT,
|
||||
idempotencyKey: "bulk-key-0000000001",
|
||||
ownerUserId: "user-1",
|
||||
queuedAt: expect.any(String),
|
||||
});
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
||||
});
|
||||
|
||||
it("lists a bulk entry under its own name", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1");
|
||||
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
expect(item).toMatchObject({
|
||||
store: "pending-bulk",
|
||||
label: "Varroamessung für mehrere Völker",
|
||||
});
|
||||
expect(item.resendWithout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the rejected colonies by code from the loaded packs", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
const raw = await openDB(DB_NAME);
|
||||
await raw.put("offline-packs", {
|
||||
apiaryId: "apiary-1",
|
||||
ownerUserId: "user-1",
|
||||
savedAt: "2026-10-14T08:00:00.000Z",
|
||||
pack: {
|
||||
apiary: { id: "apiary-1", name: "Waldrand" },
|
||||
colonies: [
|
||||
{ id: "c-1", code: "V-1", apiaryId: "apiary-1", status: "ACTIVE" },
|
||||
{ id: "c-3", code: "V-3", apiaryId: "apiary-1", status: "DISSOLVED" },
|
||||
],
|
||||
},
|
||||
});
|
||||
raw.close();
|
||||
const entry = await onlyBulkEntry();
|
||||
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
|
||||
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
expect(item.resendWithout).toEqual({ colonyIds: ["c-3"], codes: ["V-3"] });
|
||||
});
|
||||
|
||||
it("offers no resend when every colony of the entry was rejected", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
|
||||
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
expect(item.resendWithout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retryBulkWithout resends the remaining colonies with the same key and clears the rejection", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
|
||||
|
||||
await db.retryBulkWithout(entry.id, ["c-3"]);
|
||||
|
||||
expect(await onlyBulkEntry()).toEqual({
|
||||
...entry,
|
||||
input: { ...BULK_TREATMENT, colonyIds: ["c-1", "c-2"] },
|
||||
lastError: undefined,
|
||||
});
|
||||
expect((await onlyBulkEntry()).idempotencyKey).toBe("bulk-key-0000000001");
|
||||
});
|
||||
|
||||
it("retryBulkWithout drops a varroa colony with its own count", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
|
||||
await db.retryBulkWithout(entry.id, ["c-3"]);
|
||||
|
||||
expect((await onlyBulkEntry()).input).toEqual({
|
||||
...BULK_VARROA,
|
||||
entries: [{ colonyId: "c-1", miteCount: 4 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("retryBulkWithout removes an entry that would be left without colonies", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
|
||||
await db.retryBulkWithout(entry.id, ["c-3"]);
|
||||
|
||||
expect(await onlyBulkEntry()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discardQueueItem deletes a bulk entry", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("feedings", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
await db.discardQueueItem(item);
|
||||
|
||||
expect(await onlyBulkEntry()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import { CreateFeedingInput } from "@/lib/feedings/types";
|
||||
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
|
||||
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
|
||||
import { CreateHarvestInput } from "@/lib/harvests/types";
|
||||
import {
|
||||
colonyIdsOfBulkInput,
|
||||
invalidColonyIdsOfBody,
|
||||
withoutColonies,
|
||||
} from "@/lib/bulk/bulk-input";
|
||||
|
||||
/** What each single-record queue (one per quick-capture form) holds. */
|
||||
export interface RecordQueueInputs {
|
||||
@@ -191,6 +196,15 @@ function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A new queue entry; the store gives it its id when it is added. */
|
||||
export function newPendingEntry<T>(
|
||||
input: T,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Omit<PendingEntry<T>, "id"> {
|
||||
return { input, idempotencyKey, ownerUserId, queuedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async function queue<Store extends RecordStoreName>(
|
||||
store: Store,
|
||||
input: RecordQueueInputs[Store],
|
||||
@@ -198,13 +212,7 @@ async function queue<Store extends RecordStoreName>(
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
|
||||
input,
|
||||
idempotencyKey,
|
||||
ownerUserId,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.add(store, entry as never);
|
||||
await db.add(store, newPendingEntry(input, idempotencyKey, ownerUserId) as never);
|
||||
}
|
||||
|
||||
export function queueInspection(
|
||||
@@ -255,6 +263,27 @@ export function queueHarvest(
|
||||
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry for several colonies. The key belongs to this exact selection of
|
||||
* colonies: it is stored with the entry and sent again unchanged.
|
||||
*/
|
||||
export async function queueBulk(
|
||||
kind: BulkKind,
|
||||
input: unknown,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const entry: Omit<PendingBulkEntry, "id"> = {
|
||||
kind,
|
||||
input,
|
||||
idempotencyKey,
|
||||
ownerUserId,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.add("pending-bulk", entry as PendingBulkEntry);
|
||||
}
|
||||
|
||||
/** Every entry of one record queue, oldest first, of every owner. */
|
||||
export async function getPendingEntries<Store extends RecordStoreName>(
|
||||
store: Store,
|
||||
@@ -272,6 +301,18 @@ export async function getPendingEntry<Store extends RecordStoreName>(
|
||||
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
|
||||
}
|
||||
|
||||
/** Every bulk entry, oldest first, of every owner. */
|
||||
export async function getBulkEntries(): Promise<PendingBulkEntry[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll("pending-bulk");
|
||||
}
|
||||
|
||||
/** One bulk entry as it is stored right now, or undefined once it is gone. */
|
||||
export async function getBulkEntry(id: number): Promise<PendingBulkEntry | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get("pending-bulk", id);
|
||||
}
|
||||
|
||||
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete(store, id);
|
||||
@@ -300,6 +341,32 @@ export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: s
|
||||
return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Ohne diese Völker erneut senden“: takes the colonies out of a queued bulk
|
||||
* entry and clears its rejection, so the next sync run sends the rest. The
|
||||
* key stays: the server stored nothing for the rejected try and released it.
|
||||
* An entry left without any colony is removed.
|
||||
*/
|
||||
export async function retryBulkWithout(
|
||||
entryId: number,
|
||||
colonyIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-bulk", "readwrite");
|
||||
const entry = await tx.store.get(entryId);
|
||||
if (entry) {
|
||||
const input = withoutColonies(entry.kind, entry.input, colonyIds);
|
||||
if (colonyIdsOfBulkInput(entry.kind, input).length === 0) {
|
||||
await tx.store.delete(entryId);
|
||||
} else {
|
||||
const cleared: PendingBulkEntry = { ...entry, input };
|
||||
delete cleared.lastError;
|
||||
await tx.store.put(cleared);
|
||||
}
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
|
||||
|
||||
interface QueuedRecord {
|
||||
@@ -366,6 +433,11 @@ export interface QueueItem {
|
||||
queuedAt: string;
|
||||
lastError?: QueueError;
|
||||
photoCount?: number;
|
||||
/**
|
||||
* A rejected bulk entry that can go out without the colonies the server
|
||||
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
|
||||
*/
|
||||
resendWithout?: { colonyIds: string[]; codes: string[] };
|
||||
}
|
||||
|
||||
function labelOf({ store, entry }: QueuedRecord): string {
|
||||
@@ -375,6 +447,27 @@ function labelOf({ store, entry }: QueuedRecord): string {
|
||||
return RECORD_LABELS[store];
|
||||
}
|
||||
|
||||
async function resendWithoutOf(
|
||||
db: IDBPDatabase<OfflineDB>,
|
||||
entry: PendingBulkEntry,
|
||||
): Promise<QueueItem["resendWithout"]> {
|
||||
const all = colonyIdsOfBulkInput(entry.kind, entry.input);
|
||||
const rejected = invalidColonyIdsOfBody(entry.lastError?.details).filter((id) =>
|
||||
all.includes(id),
|
||||
);
|
||||
if (rejected.length === 0 || rejected.length === all.length) {
|
||||
return undefined;
|
||||
}
|
||||
const packs = await db.getAll("offline-packs");
|
||||
const codeOf = new Map(
|
||||
packs.flatMap((stored) => stored.pack.colonies.map((colony) => [colony.id, colony.code])),
|
||||
);
|
||||
return {
|
||||
colonyIds: rejected,
|
||||
codes: rejected.flatMap((id) => codeOf.get(id) ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user's waiting and rejected entries, oldest first. */
|
||||
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
const db = await getDb();
|
||||
@@ -389,6 +482,10 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
queuedAt: entry.queuedAt,
|
||||
...(entry.lastError ? { lastError: entry.lastError } : {}),
|
||||
};
|
||||
if (store === "pending-bulk") {
|
||||
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
|
||||
return resendWithout ? { ...item, resendWithout } : item;
|
||||
}
|
||||
if (store !== "pending-inspections" || !entry.idempotencyKey) {
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("idb", () => {
|
||||
// The photos that follow a queued inspection are covered in lib/photos/store.spec.ts.
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
resolveQueuedInspection: vi.fn(async () => undefined),
|
||||
finishHandOvers: vi.fn(async () => 0),
|
||||
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import { withPackFallback } from "./pack-fallback";
|
||||
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
|
||||
|
||||
const PACKS = [storedPack(offlinePack({ apiaryId: "apiary-1", apiaryName: "Obstwiese" }))];
|
||||
|
||||
const apiaryNames = vi.fn((packs: typeof PACKS) => packs.map((pack) => pack.pack.apiary.name));
|
||||
|
||||
describe("withPackFallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue(PACKS);
|
||||
apiaryNames.mockClear();
|
||||
});
|
||||
|
||||
it("answers from the server whenever the server answers", async () => {
|
||||
const result = await withPackFallback(async () => ["vom Server"], apiaryNames, "user-1");
|
||||
|
||||
expect(result).toEqual(["vom Server"]);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the pack on a network error", async () => {
|
||||
const result = await withPackFallback(
|
||||
() => Promise.reject(new TypeError("Failed to fetch")),
|
||||
apiaryNames,
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result).toEqual(["Obstwiese"]);
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("falls back to the pack when the request timed out", async () => {
|
||||
const timeout = new DOMException("Zeitüberschreitung der Anfrage", "TimeoutError");
|
||||
|
||||
const result = await withPackFallback(() => Promise.reject(timeout), apiaryNames, "user-1");
|
||||
|
||||
expect(result).toEqual(["Obstwiese"]);
|
||||
});
|
||||
|
||||
it("rethrows an ApiError instead of showing pack data", async () => {
|
||||
for (const status of [403, 404, 500]) {
|
||||
const rejection = new ApiError(status, "Nein");
|
||||
|
||||
await expect(
|
||||
withPackFallback(() => Promise.reject(rejection), apiaryNames, "user-1"),
|
||||
).rejects.toBe(rejection);
|
||||
}
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
expect(apiaryNames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rethrows when the pack has nothing", async () => {
|
||||
const offline = new TypeError("Failed to fetch");
|
||||
|
||||
await expect(
|
||||
withPackFallback(() => Promise.reject(offline), () => null, "user-1"),
|
||||
).rejects.toBe(offline);
|
||||
});
|
||||
|
||||
it("rethrows the network error when the packs cannot be read", async () => {
|
||||
const offline = new TypeError("Failed to fetch");
|
||||
vi.mocked(listPacks).mockRejectedValue(new Error("IndexedDB gesperrt"));
|
||||
|
||||
await expect(
|
||||
withPackFallback(() => Promise.reject(offline), apiaryNames, "user-1"),
|
||||
).rejects.toBe(offline);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import type { StoredPack } from "./db";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
|
||||
/**
|
||||
* The server's answer - or, only when the network failed, what the user's
|
||||
* offline packs say. A server refusal (ApiError: 403, 404, 400 ...) is never
|
||||
* covered up with pack data: the server knows better than an old pack.
|
||||
* When the packs have nothing (`fromPacks` returns null) or cannot be read,
|
||||
* the network error is passed on, so the caller shows "not loaded".
|
||||
*
|
||||
* Queries built on this read local storage when offline: give them
|
||||
* `networkMode: "always"`, or a paused retry would hide the fallback.
|
||||
*/
|
||||
export async function withPackFallback<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
fromPacks: (packs: StoredPack[]) => T | null,
|
||||
ownerUserId: string,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fetcher();
|
||||
} catch (error) {
|
||||
if (!isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const fallback = await listPacks(ownerUserId).then(fromPacks, () => null);
|
||||
if (fallback === null) {
|
||||
throw error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
entriesNotSent,
|
||||
entriesRejected,
|
||||
entriesSynced,
|
||||
entriesWaiting,
|
||||
foreignEntriesWaiting,
|
||||
photosAttached,
|
||||
photosNotTakenOver,
|
||||
sessionExpiredWithEntries,
|
||||
} from "./queue-texts";
|
||||
|
||||
describe("queue texts", () => {
|
||||
@@ -14,6 +17,7 @@ describe("queue texts", () => {
|
||||
expect(foreignEntriesWaiting(1)).toBe("1 Eintrag eines anderen Kontos wartet");
|
||||
expect(entriesSynced(1)).toBe("1 Eintrag synchronisiert");
|
||||
expect(photosAttached(1)).toBe("+ 1 Foto");
|
||||
expect(photosNotTakenOver(1)).toBe("1 Foto wurde nicht übernommen und verworfen");
|
||||
});
|
||||
|
||||
it("uses the plural for two entries", () => {
|
||||
@@ -22,5 +26,21 @@ describe("queue texts", () => {
|
||||
expect(foreignEntriesWaiting(2)).toBe("2 Einträge eines anderen Kontos warten");
|
||||
expect(entriesSynced(2)).toBe("2 Einträge synchronisiert");
|
||||
expect(photosAttached(2)).toBe("+ 2 Fotos");
|
||||
expect(photosNotTakenOver(2)).toBe("2 Fotos wurden nicht übernommen und verworfen");
|
||||
});
|
||||
|
||||
it("says how many entries are not sent yet when signing out", () => {
|
||||
expect(entriesNotSent(1)).toBe("1 Eintrag ist noch nicht gesendet.");
|
||||
expect(entriesNotSent(3)).toBe("3 Einträge sind noch nicht gesendet.");
|
||||
});
|
||||
|
||||
it("tells an expired session apart from the entries that still wait", () => {
|
||||
expect(sessionExpiredWithEntries(0)).toBe("Sitzung abgelaufen – bitte neu anmelden.");
|
||||
expect(sessionExpiredWithEntries(1)).toBe(
|
||||
"Sitzung abgelaufen – bitte neu anmelden. 1 Eintrag wartet und wird danach gesendet.",
|
||||
);
|
||||
expect(sessionExpiredWithEntries(4)).toBe(
|
||||
"Sitzung abgelaufen – bitte neu anmelden. 4 Einträge warten und werden danach gesendet.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,3 +24,23 @@ export function entriesSynced(n: number): string {
|
||||
export function photosAttached(n: number): string {
|
||||
return `+ ${count(n, "Foto", "Fotos")}`;
|
||||
}
|
||||
|
||||
/** Photos of a fresh form that did not fit into a restored draft and were deleted. */
|
||||
export function photosNotTakenOver(n: number): string {
|
||||
return `${count(n, "Foto wurde", "Fotos wurden")} nicht übernommen und verworfen`;
|
||||
}
|
||||
|
||||
/** Sign-out dialog: "3 Einträge sind noch nicht gesendet." */
|
||||
export function entriesNotSent(n: number): string {
|
||||
return n === 1 ? "1 Eintrag ist noch nicht gesendet." : `${n} Einträge sind noch nicht gesendet.`;
|
||||
}
|
||||
|
||||
/** The refresh found the session over: the entries wait for the next sign-in. */
|
||||
export function sessionExpiredWithEntries(n: number): string {
|
||||
const expired = "Sitzung abgelaufen – bitte neu anmelden.";
|
||||
if (n === 0) {
|
||||
return expired;
|
||||
}
|
||||
const rest = n === 1 ? "1 Eintrag wartet und wird danach gesendet." : `${n} Einträge warten und werden danach gesendet.`;
|
||||
return `${expired} ${rest}`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { safeReturnPath } from "./return-path";
|
||||
|
||||
describe("safeReturnPath", () => {
|
||||
it("keeps a path of this app, with its query", () => {
|
||||
expect(safeReturnPath("/colonies/abc")).toBe("/colonies/abc");
|
||||
expect(safeReturnPath("/statistics?year=2026")).toBe("/statistics?year=2026");
|
||||
expect(safeReturnPath("/colonies/a%20b")).toBe("/colonies/a%20b");
|
||||
});
|
||||
|
||||
it("never leads to another site", () => {
|
||||
for (const unsafe of [
|
||||
"https://evil.example/colonies/abc",
|
||||
"//evil.example/colonies",
|
||||
"/\\evil.example",
|
||||
"\\\\evil.example",
|
||||
"javascript:alert(1)",
|
||||
" /colonies/abc",
|
||||
"colonies/abc",
|
||||
]) {
|
||||
expect(safeReturnPath(unsafe)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("never leads back into the offline pages, the API or nowhere", () => {
|
||||
expect(safeReturnPath("/offline")).toBeNull();
|
||||
expect(safeReturnPath("/offline/colony?id=abc")).toBeNull();
|
||||
expect(safeReturnPath("/api/v1/colonies")).toBeNull();
|
||||
expect(safeReturnPath("")).toBeNull();
|
||||
expect(safeReturnPath(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Any origin works as the base: only a result on this same origin is accepted.
|
||||
const BASE = "https://apicore.invalid";
|
||||
|
||||
/**
|
||||
* The `from` the service worker hands to the offline pages - the page the
|
||||
* user wanted - as a path of this app, or null. Never another site (no open
|
||||
* redirect), never back into the offline pages or the API.
|
||||
*/
|
||||
export function safeReturnPath(raw: string | null): string | null {
|
||||
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw, BASE);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== BASE) {
|
||||
return null;
|
||||
}
|
||||
const isOffline = url.pathname === "/offline" || url.pathname.startsWith("/offline/");
|
||||
if (isOffline || url.pathname === "/api" || url.pathname.startsWith("/api/")) {
|
||||
return null;
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type OfflineFallback = { kind: "redirect"; to: string } | { kind: "cached-path"; path: string };
|
||||
type NavigationPlan =
|
||||
| { kind: "browser" }
|
||||
| { kind: "cache-first" }
|
||||
| { kind: "network-first"; timeoutMs: number; redirectOnTimeout: boolean };
|
||||
|
||||
interface SwRoutes {
|
||||
SHELL_ROUTES: readonly string[];
|
||||
offlineFallbackFor: (url: URL) => OfflineFallback;
|
||||
offlineHomeFor: (url: URL) => string;
|
||||
navigationPlanFor: (url: URL) => NavigationPlan;
|
||||
isShellRoute: (pathname: string) => boolean;
|
||||
extractAssetUrls: (html: string) => string[];
|
||||
isCacheableApiPath: (pathname: string) => boolean;
|
||||
shouldCacheApi: (url: URL, apiOrigin: string | null) => boolean;
|
||||
apiOriginFrom: (config: unknown) => string | null;
|
||||
isRoutePayload: (url: URL) => boolean;
|
||||
buildAssetsToPrune: (
|
||||
cached: { url: string; date: number | null }[],
|
||||
current: readonly string[],
|
||||
now: number,
|
||||
) => string[];
|
||||
}
|
||||
|
||||
// The service worker's routing rules are a plain script (public/), loaded by
|
||||
// the worker with importScripts - here as CommonJS.
|
||||
const require = createRequire(import.meta.url);
|
||||
// Vitest runs from the frontend root.
|
||||
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
|
||||
const routes = require(path.join(PUBLIC_DIR, "sw-routes.js")) as SwRoutes;
|
||||
|
||||
const APP = "https://apicore.example";
|
||||
const API = "https://apicorebackend.example";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function fallbackFor(path: string): OfflineFallback {
|
||||
return routes.offlineFallbackFor(new URL(path, APP));
|
||||
}
|
||||
|
||||
function planFor(path: string): NavigationPlan {
|
||||
return routes.navigationPlanFor(new URL(path, APP));
|
||||
}
|
||||
|
||||
function cachesApi(path: string, origin = API): boolean {
|
||||
return routes.shouldCacheApi(new URL(path, origin), API);
|
||||
}
|
||||
|
||||
describe("service worker routes", () => {
|
||||
it("maps /colonies/abc to /offline/colony?id=abc and encodes ids", () => {
|
||||
expect(fallbackFor("/colonies/abc")).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/offline/colony?id=abc&from=%2Fcolonies%2Fabc",
|
||||
});
|
||||
expect(fallbackFor("/colonies/abc/")).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/offline/colony?id=abc&from=%2Fcolonies%2Fabc%2F",
|
||||
});
|
||||
expect(fallbackFor("/apiaries/xyz")).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/offline/apiary?id=xyz&from=%2Fapiaries%2Fxyz",
|
||||
});
|
||||
expect(fallbackFor("/colonies/a%20b%26c%3Dd")).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/offline/colony?id=a%20b%26c%3Dd&from=%2Fcolonies%2Fa%2520b%2526c%253Dd",
|
||||
});
|
||||
expect(fallbackFor("/colonies/a&b=c#x")).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/offline/colony?id=a%26b%3Dc&from=%2Fcolonies%2Fa%26b%3Dc",
|
||||
});
|
||||
});
|
||||
|
||||
it("serves /inspections/new?colonyId=x from the cached shell path", () => {
|
||||
expect(fallbackFor("/inspections/new?colonyId=x&round=apiary-1")).toEqual({
|
||||
kind: "cached-path",
|
||||
path: "/inspections/new",
|
||||
});
|
||||
expect(fallbackFor("/offline/colony?id=abc")).toEqual({
|
||||
kind: "cached-path",
|
||||
path: "/offline/colony",
|
||||
});
|
||||
expect(fallbackFor("/dashboard/")).toEqual({ kind: "cached-path", path: "/dashboard" });
|
||||
for (const route of [
|
||||
"/",
|
||||
"/login",
|
||||
"/dashboard",
|
||||
"/offline",
|
||||
"/offline/apiary",
|
||||
"/offline/colony",
|
||||
"/inspections/new",
|
||||
"/varroa/new",
|
||||
"/treatments/new",
|
||||
"/feedings/new",
|
||||
"/swarm-control/new",
|
||||
]) {
|
||||
expect(routes.isShellRoute(route)).toBe(true);
|
||||
}
|
||||
expect(routes.SHELL_ROUTES).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("unknown routes go to /offline and remember where the user wanted to go", () => {
|
||||
expect(fallbackFor("/statistics?year=2026")).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/offline?from=%2Fstatistics%3Fyear%3D2026",
|
||||
});
|
||||
for (const path of [
|
||||
"/colonies",
|
||||
"/colonies/new",
|
||||
"/apiaries/new",
|
||||
"/apiaries/map",
|
||||
"/colonies/abc/edit",
|
||||
"/colonies/%E0%A4%A",
|
||||
"/harvests/new",
|
||||
]) {
|
||||
const fallback = fallbackFor(path);
|
||||
expect(fallback.kind).toBe("redirect");
|
||||
expect(fallback.kind === "redirect" && fallback.to.startsWith("/offline?from=")).toBe(true);
|
||||
}
|
||||
expect(routes.isShellRoute("/statistics")).toBe(false);
|
||||
expect(routes.offlineHomeFor(new URL("/queens/q-1", APP))).toBe("/offline?from=%2Fqueens%2Fq-1");
|
||||
});
|
||||
|
||||
it("plans each navigation by its route", () => {
|
||||
expect(planFor("/offline")).toEqual({ kind: "cache-first" });
|
||||
expect(planFor("/offline/apiary?id=a-1")).toEqual({ kind: "cache-first" });
|
||||
expect(planFor("/offline/colony?id=c-1")).toEqual({ kind: "cache-first" });
|
||||
// Shell pages answer from the cache after 4 s - without one, the network
|
||||
// keeps its chance (no dead end on a slow line).
|
||||
expect(planFor("/inspections/new?colonyId=c-1")).toEqual({
|
||||
kind: "network-first",
|
||||
timeoutMs: 4000,
|
||||
redirectOnTimeout: false,
|
||||
});
|
||||
expect(planFor("/")).toEqual({ kind: "network-first", timeoutMs: 4000, redirectOnTimeout: false });
|
||||
// A scanned QR code at the apiary: the offline view after 10 s.
|
||||
expect(planFor("/colonies/c-1")).toEqual({
|
||||
kind: "network-first",
|
||||
timeoutMs: 10000,
|
||||
redirectOnTimeout: true,
|
||||
});
|
||||
expect(planFor("/apiaries/a-1")).toEqual({
|
||||
kind: "network-first",
|
||||
timeoutMs: 10000,
|
||||
redirectOnTimeout: true,
|
||||
});
|
||||
expect(planFor("/statistics")).toEqual({
|
||||
kind: "network-first",
|
||||
timeoutMs: 15000,
|
||||
redirectOnTimeout: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves navigations to /api/ to the browser", () => {
|
||||
expect(planFor("/api/runtime-config")).toEqual({ kind: "browser" });
|
||||
expect(planFor("/api/v1/documents/d-1/download")).toEqual({ kind: "browser" });
|
||||
});
|
||||
|
||||
it("never caches /api/v1/auth/*", () => {
|
||||
for (const path of [
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/sso/status",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/Auth/me",
|
||||
]) {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
}
|
||||
expect(cachesApi("/api/v1/authors")).toBe(true);
|
||||
});
|
||||
|
||||
it("never caches what the offline packs answer, so the pack answers offline", () => {
|
||||
for (const path of [
|
||||
"/api/v1/colonies",
|
||||
"/api/v1/colonies/",
|
||||
"/api/v1/colonies?status=ACTIVE",
|
||||
"/api/v1/colonies/c-1/inspections",
|
||||
"/api/v1/apiaries/a-1/colony-overview",
|
||||
"/api/v1/apiaries/a-1/offline-pack",
|
||||
]) {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
}
|
||||
expect(cachesApi("/api/v1/colonies/c-1")).toBe(true);
|
||||
expect(cachesApi("/api/v1/tasks")).toBe(true);
|
||||
});
|
||||
|
||||
it("never caches apiaries themselves - they carry coordinates and the address", () => {
|
||||
for (const path of ["/api/v1/apiaries", "/api/v1/apiaries/", "/api/v1/apiaries/a-1", "/api/v1/apiaries/a-1/"]) {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
}
|
||||
expect(cachesApi("/api/v1/apiaries/a-1/weather")).toBe(true);
|
||||
});
|
||||
|
||||
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
|
||||
for (const path of [
|
||||
"/api/v1/export/apiaries.json",
|
||||
"/api/v1/export/treatment-log.pdf",
|
||||
"/api/v1/export/colonies.csv",
|
||||
"/api/v1/Export/apiaries.json",
|
||||
"/api/v1/backup/export",
|
||||
"/api/v1/backup",
|
||||
]) {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
}
|
||||
expect(cachesApi("/api/v1/exporters")).toBe(true);
|
||||
});
|
||||
|
||||
it("caches only answers of the configured backend", () => {
|
||||
expect(cachesApi("/api/v1/tasks", "https://elsewhere.example")).toBe(false);
|
||||
expect(routes.shouldCacheApi(new URL("/api/v1/tasks", API), null)).toBe(false);
|
||||
expect(routes.shouldCacheApi(new URL("/api/runtime-config", APP), APP)).toBe(false);
|
||||
expect(routes.shouldCacheApi(new URL("/_next/static/chunks/main.js", APP), APP)).toBe(false);
|
||||
expect(routes.apiOriginFrom({ apiUrl: `${API}/api/v1` })).toBe(API);
|
||||
expect(routes.apiOriginFrom({ apiUrl: "kein url" })).toBeNull();
|
||||
expect(routes.apiOriginFrom(null)).toBeNull();
|
||||
expect(routes.isCacheableApiPath("/api/v1/apiaries/a-1")).toBe(false);
|
||||
expect(routes.isCacheableApiPath("/api/v1/tasks")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises route payloads, which are never kept", () => {
|
||||
expect(routes.isRoutePayload(new URL("/dashboard?_rsc=1x2y", APP))).toBe(true);
|
||||
expect(routes.isRoutePayload(new URL("/dashboard", APP))).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts _next/static asset urls", () => {
|
||||
const html = `<!DOCTYPE html><html><head>
|
||||
<link rel="stylesheet" href="/_next/static/css/app-1a2b.css" data-precedence="next"/>
|
||||
<link rel="preload" href="/_next/static/media/inter-3c4d.p.woff2" as="font" crossorigin=""/>
|
||||
<script src="/_next/static/chunks/webpack-5e6f.js" async=""></script>
|
||||
<script src='/_next/static/chunks/app/page-7a8b.js?dpl=abc' async></script>
|
||||
<script src="https://cdn.example/_next/static/chunks/foreign.js"></script>
|
||||
<script src="/_next/static/chunks/webpack-5e6f.js" async=""></script>
|
||||
</head><body><script>self.__next_f.push([1,"0:[\\"/_next/static/chunks/lazy-9c0d.js\\"]"])</script></body></html>`;
|
||||
|
||||
expect(routes.extractAssetUrls(html).sort()).toEqual([
|
||||
"/_next/static/chunks/app/page-7a8b.js?dpl=abc",
|
||||
"/_next/static/chunks/lazy-9c0d.js",
|
||||
"/_next/static/chunks/webpack-5e6f.js",
|
||||
"/_next/static/css/app-1a2b.css",
|
||||
"/_next/static/media/inter-3c4d.p.woff2",
|
||||
]);
|
||||
expect(routes.extractAssetUrls("<html><body>Keine Assets</body></html>")).toEqual([]);
|
||||
});
|
||||
|
||||
it("prunes build assets of old builds, keeping current and recently used ones", () => {
|
||||
const now = Date.parse("2026-10-14T12:00:00.000Z");
|
||||
const cached = [
|
||||
{ url: `${APP}/_next/static/chunks/current.js`, date: now - 30 * DAY_MS },
|
||||
{ url: `${APP}/_next/static/chunks/old.js`, date: now - 8 * DAY_MS },
|
||||
{ url: `${APP}/_next/static/chunks/recent-lazy.js`, date: now - DAY_MS },
|
||||
{ url: `${APP}/_next/static/chunks/undated.js`, date: null },
|
||||
{ url: `${APP}/icon.svg`, date: now - 90 * DAY_MS },
|
||||
];
|
||||
|
||||
expect(
|
||||
routes.buildAssetsToPrune(cached, ["/_next/static/chunks/current.js"], now).sort(),
|
||||
).toEqual([`${APP}/_next/static/chunks/old.js`, `${APP}/_next/static/chunks/undated.js`]);
|
||||
});
|
||||
|
||||
it("sw.js imports sw-routes.js under its content version, so every change reaches the phones", () => {
|
||||
const worker = readFileSync(path.join(PUBLIC_DIR, "sw.js"), "utf8");
|
||||
const rules = readFileSync(path.join(PUBLIC_DIR, "sw-routes.js"), "utf8").replace(/\r\n/g, "\n");
|
||||
const version = createHash("sha256").update(rules).digest("hex").slice(0, 12);
|
||||
|
||||
expect(worker).toContain(`const SW_ROUTES_VERSION = "${version}";`);
|
||||
expect(worker).toContain("importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// public/sw.js itself, run in a sandbox with an in-memory Cache Storage and a
|
||||
// fake network - the worker's wiring, beyond the pure rules in sw-routes.js.
|
||||
|
||||
const APP = "https://apicore.example";
|
||||
const API = "https://apicorebackend.example";
|
||||
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
|
||||
|
||||
interface MatchOptions {
|
||||
ignoreSearch?: boolean;
|
||||
}
|
||||
|
||||
function keyOf(request: RequestInfo | URL): string {
|
||||
const raw = typeof request === "string" || request instanceof URL ? String(request) : request.url;
|
||||
return new URL(raw, APP).href;
|
||||
}
|
||||
|
||||
function withoutSearch(href: string): string {
|
||||
const url = new URL(href);
|
||||
return url.origin + url.pathname;
|
||||
}
|
||||
|
||||
class FakeCache {
|
||||
readonly entries = new Map<string, Response>();
|
||||
|
||||
constructor(private readonly log: string[], private readonly name: string) {}
|
||||
|
||||
async match(request: RequestInfo | URL, options: MatchOptions = {}): Promise<Response | undefined> {
|
||||
const key = keyOf(request);
|
||||
const found = options.ignoreSearch
|
||||
? [...this.entries.entries()].find(([stored]) => withoutSearch(stored) === withoutSearch(key))?.[1]
|
||||
: this.entries.get(key);
|
||||
return found?.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
const key = keyOf(request);
|
||||
// Like the real Cache API: the body is read completely before it is stored.
|
||||
const body = await response.arrayBuffer();
|
||||
this.entries.set(key, new Response(body, { status: response.status, headers: response.headers }));
|
||||
this.log.push(`${this.name} ${new URL(key).pathname}`);
|
||||
}
|
||||
|
||||
async delete(request: RequestInfo | URL): Promise<boolean> {
|
||||
return this.entries.delete(keyOf(request));
|
||||
}
|
||||
|
||||
async keys(): Promise<Request[]> {
|
||||
return [...this.entries.keys()].map((key) => new Request(key));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCacheStorage {
|
||||
readonly stores = new Map<string, FakeCache>();
|
||||
readonly log: string[] = [];
|
||||
|
||||
async open(name: string): Promise<FakeCache> {
|
||||
if (!this.stores.has(name)) {
|
||||
this.stores.set(name, new FakeCache(this.log, name));
|
||||
}
|
||||
return this.stores.get(name) as FakeCache;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
return [...this.stores.keys()];
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.stores.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = (event: unknown) => void;
|
||||
|
||||
interface Worker {
|
||||
caches: FakeCacheStorage;
|
||||
network: ReturnType<typeof vi.fn>;
|
||||
handlers: Map<string, Handler>;
|
||||
}
|
||||
|
||||
function loadWorker(caches: FakeCacheStorage, network: ReturnType<typeof vi.fn>): Worker {
|
||||
const handlers = new Map<string, Handler>();
|
||||
const self = {
|
||||
location: new URL(`${APP}/sw.js`),
|
||||
addEventListener: (type: string, handler: Handler) => handlers.set(type, handler),
|
||||
skipWaiting: () => Promise.resolve(),
|
||||
clients: { claim: () => Promise.resolve() },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
self,
|
||||
caches,
|
||||
fetch: network,
|
||||
URL,
|
||||
Request,
|
||||
Response,
|
||||
Headers,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
importScripts: () => {
|
||||
vm.runInContext(readFileSync(path.join(PUBLIC_DIR, "sw-routes.js"), "utf8"), context);
|
||||
},
|
||||
});
|
||||
vm.runInContext(readFileSync(path.join(PUBLIC_DIR, "sw.js"), "utf8"), context);
|
||||
return { caches, network, handlers };
|
||||
}
|
||||
|
||||
/** Dispatches an event and waits for its answer and everything it keeps alive. */
|
||||
async function dispatch(worker: Worker, type: string, fields: Record<string, unknown>) {
|
||||
let answer: Promise<Response> | undefined;
|
||||
const pending: Promise<unknown>[] = [];
|
||||
const event = {
|
||||
...fields,
|
||||
respondWith: (promise: Promise<Response>) => {
|
||||
answer = Promise.resolve(promise);
|
||||
},
|
||||
waitUntil: (promise: Promise<unknown>) => {
|
||||
pending.push(promise);
|
||||
},
|
||||
};
|
||||
worker.handlers.get(type)?.(event);
|
||||
const response = await answer?.catch(() => undefined);
|
||||
for (let settled = 0; settled < pending.length; settled = pending.length) {
|
||||
await Promise.allSettled(pending.slice(settled));
|
||||
}
|
||||
return { responded: answer !== undefined, response };
|
||||
}
|
||||
|
||||
function apiGet(worker: Worker, url: string) {
|
||||
return dispatch(worker, "fetch", { request: new Request(url) });
|
||||
}
|
||||
|
||||
function navigate(worker: Worker, url: string) {
|
||||
const request = { url, method: "GET", mode: "navigate", headers: new Headers() };
|
||||
return dispatch(worker, "fetch", { request });
|
||||
}
|
||||
|
||||
function json(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json; charset=utf-8" } });
|
||||
}
|
||||
|
||||
function html(body: string): Response {
|
||||
return new Response(body, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||
}
|
||||
|
||||
function page(asset: string, marker: string): string {
|
||||
return `<!doctype html><html><head><script src="${asset}" async=""></script></head><body>${marker}</body></html>`;
|
||||
}
|
||||
|
||||
async function workerWithBackend(network: ReturnType<typeof vi.fn>): Promise<Worker> {
|
||||
const caches = new FakeCacheStorage();
|
||||
const shell = await caches.open("apicore-shell-v4");
|
||||
await shell.put("/api/runtime-config", json({ apiUrl: `${API}/api/v1` }));
|
||||
const worker = loadWorker(caches, network);
|
||||
// Let the worker read the backend origin from the cached config.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return worker;
|
||||
}
|
||||
|
||||
async function dataKeys(worker: Worker): Promise<string[]> {
|
||||
const data = worker.caches.stores.get("apicore-data-v2");
|
||||
return data ? [...data.entries.keys()].map((key) => new URL(key).pathname) : [];
|
||||
}
|
||||
|
||||
describe("service worker", () => {
|
||||
let network: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
network = vi.fn();
|
||||
});
|
||||
|
||||
it("keeps JSON answers of the backend, never downloads, photos or other non-JSON answers", async () => {
|
||||
const worker = await workerWithBackend(network);
|
||||
network.mockImplementation(async (request: Request) => {
|
||||
const { pathname } = new URL(request.url);
|
||||
if (pathname.endsWith("/download")) {
|
||||
return new Response("%PDF-1.7", { headers: { "Content-Type": "application/pdf" } });
|
||||
}
|
||||
if (pathname.endsWith("/avatar")) {
|
||||
return new Response("jpeg", { headers: { "Content-Type": "image/jpeg" } });
|
||||
}
|
||||
return json([{ id: "t-1" }]);
|
||||
});
|
||||
|
||||
await apiGet(worker, `${API}/api/v1/tasks`);
|
||||
const download = await apiGet(worker, `${API}/api/v1/documents/d-1/download`);
|
||||
await apiGet(worker, `${API}/api/v1/profile/avatar`);
|
||||
|
||||
expect(await download.response?.text()).toBe("%PDF-1.7");
|
||||
expect(await dataKeys(worker)).toEqual(["/api/v1/tasks"]);
|
||||
});
|
||||
|
||||
it("leaves exports and backups to the network entirely", async () => {
|
||||
const worker = await workerWithBackend(network);
|
||||
network.mockResolvedValue(json([{ latitude: 52.1, address: "Feldweg 3" }]));
|
||||
|
||||
const exported = await apiGet(worker, `${API}/api/v1/export/apiaries.json`);
|
||||
const backup = await apiGet(worker, `${API}/api/v1/backup/export`);
|
||||
|
||||
expect(exported.responded).toBe(false);
|
||||
expect(backup.responded).toBe(false);
|
||||
expect(await dataKeys(worker)).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops the data cache of earlier versions on activation", async () => {
|
||||
const caches = new FakeCacheStorage();
|
||||
await (await caches.open("apicore-data-v1")).put(`${API}/api/v1/apiaries/a-1`, json({ latitude: 52.1 }));
|
||||
const worker = loadWorker(caches, network);
|
||||
|
||||
await dispatch(worker, "activate", {});
|
||||
|
||||
expect(await caches.keys()).not.toContain("apicore-data-v1");
|
||||
});
|
||||
|
||||
it("after WIPE_CACHES keeps nothing until the backend is known again", async () => {
|
||||
const worker = await workerWithBackend(network);
|
||||
network.mockImplementation(async () => json([{ id: "t-1" }]));
|
||||
await apiGet(worker, `${API}/api/v1/tasks`);
|
||||
expect(await dataKeys(worker)).toEqual(["/api/v1/tasks"]);
|
||||
|
||||
await dispatch(worker, "message", { data: { type: "WIPE_CACHES" }, ports: [] });
|
||||
await apiGet(worker, `${API}/api/v1/tasks`);
|
||||
|
||||
expect(await dataKeys(worker)).toEqual([]);
|
||||
});
|
||||
|
||||
describe("WIPE_CACHES while answers are on their way", () => {
|
||||
/** Sends WIPE_CACHES and returns the promise that settles when it is done. */
|
||||
function startWipe(worker: Worker): Promise<unknown> {
|
||||
let done: Promise<unknown> = Promise.resolve();
|
||||
worker.handlers.get("message")?.({
|
||||
data: { type: "WIPE_CACHES" },
|
||||
ports: [],
|
||||
waitUntil: (promise: Promise<unknown>) => {
|
||||
done = promise;
|
||||
},
|
||||
});
|
||||
return done;
|
||||
}
|
||||
|
||||
it("never stores an answer whose request began before the wipe", async () => {
|
||||
const worker = await workerWithBackend(network);
|
||||
let answer: (response: Response) => void = () => undefined;
|
||||
network.mockImplementation(
|
||||
() => new Promise<Response>((resolve) => (answer = resolve)),
|
||||
);
|
||||
const inFlight = apiGet(worker, `${API}/api/v1/tasks`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
await startWipe(worker);
|
||||
answer(json([{ id: "c-1", name: "Volk des Vorgängers" }]));
|
||||
await inFlight;
|
||||
|
||||
expect(await dataKeys(worker)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not rebuild the data cache for a request that arrives during the wipe", async () => {
|
||||
const worker = await workerWithBackend(network);
|
||||
network.mockImplementation(async () => json([{ id: "c-1" }]));
|
||||
await apiGet(worker, `${API}/api/v1/tasks`);
|
||||
|
||||
const wiping = startWipe(worker);
|
||||
const during = apiGet(worker, `${API}/api/v1/tasks`);
|
||||
await wiping;
|
||||
await during;
|
||||
|
||||
expect(await dataKeys(worker)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps storing answers of requests that began after the wipe once the backend is known", async () => {
|
||||
const worker = await workerWithBackend(network);
|
||||
network.mockImplementation(async () => json({ apiUrl: `${API}/api/v1` }));
|
||||
await startWipe(worker);
|
||||
await dispatch(worker, "fetch", { request: new Request(`${APP}/api/runtime-config`) });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
network.mockImplementation(async () => json([{ id: "c-2" }]));
|
||||
|
||||
await apiGet(worker, `${API}/api/v1/tasks`);
|
||||
|
||||
expect(await dataKeys(worker)).toEqual(["/api/v1/tasks"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("background refresh of a cached offline page", () => {
|
||||
async function workerWithOldOfflinePage(): Promise<Worker> {
|
||||
const caches = new FakeCacheStorage();
|
||||
const shell = await caches.open("apicore-shell-v4");
|
||||
await shell.put("/offline/colony", html(page("/_next/static/chunks/old.js", "alt")));
|
||||
await shell.put("/_next/static/chunks/old.js", new Response("old()"));
|
||||
caches.log.length = 0;
|
||||
return loadWorker(caches, network);
|
||||
}
|
||||
|
||||
async function cachedOfflinePage(worker: Worker): Promise<string | undefined> {
|
||||
const shell = await worker.caches.open("apicore-shell-v4");
|
||||
return (await shell.match("/offline/colony"))?.text();
|
||||
}
|
||||
|
||||
it("keeps the new page only once its assets are cached", async () => {
|
||||
const worker = await workerWithOldOfflinePage();
|
||||
network.mockImplementation(async (request: RequestInfo) => {
|
||||
const { pathname } = new URL(keyOf(request));
|
||||
if (pathname === "/offline/colony") return html(page("/_next/static/chunks/new.js", "neu"));
|
||||
if (pathname === "/_next/static/chunks/new.js") return new Response("neu()");
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
|
||||
const { response } = await navigate(worker, `${APP}/offline/colony?id=c-1`);
|
||||
|
||||
// The cached page answers at once ...
|
||||
expect(await response?.text()).toContain("alt");
|
||||
// ... and the new one is kept with its assets, the assets first.
|
||||
expect(await cachedOfflinePage(worker)).toContain("neu");
|
||||
expect(worker.caches.log).toEqual([
|
||||
"apicore-shell-v4 /_next/static/chunks/new.js",
|
||||
"apicore-shell-v4 /offline/colony",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old page when an asset of the new one cannot be fetched", async () => {
|
||||
const worker = await workerWithOldOfflinePage();
|
||||
network.mockImplementation(async (request: RequestInfo) => {
|
||||
const { pathname } = new URL(keyOf(request));
|
||||
if (pathname === "/offline/colony") return html(page("/_next/static/chunks/new.js", "neu"));
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
|
||||
await navigate(worker, `${APP}/offline/colony?id=c-1`);
|
||||
|
||||
expect(await cachedOfflinePage(worker)).toContain("alt");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { openDB } from "idb";
|
||||
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
|
||||
|
||||
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
|
||||
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||
// Only the network is faked: the bulk endpoints and their error helpers are real.
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
type OfflineDbModule = typeof import("./db");
|
||||
type SyncModule = typeof import("./sync");
|
||||
|
||||
let db: OfflineDbModule;
|
||||
let sync: SyncModule;
|
||||
let ApiError: typeof import("@/lib/auth/api-client").ApiError;
|
||||
let apiFetch: MockedFunction<typeof import("@/lib/auth/api-client").apiFetch>;
|
||||
let refreshPacksAfterSync: MockedFunction<
|
||||
typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync
|
||||
>;
|
||||
|
||||
const TREATMENT_BULK = {
|
||||
date: "2026-10-14T00:00:00.000Z",
|
||||
product: "Oxuvar",
|
||||
colonyIds: ["colony-1", "colony-2", "colony-3"],
|
||||
};
|
||||
const VARROA_BULK = {
|
||||
date: "2026-10-14T00:00:00.000Z",
|
||||
method: "NATURAL_MITE_FALL" as const,
|
||||
entries: [
|
||||
{ colonyId: "colony-1", miteCount: 4 },
|
||||
{ colonyId: "colony-2", miteCount: 9 },
|
||||
],
|
||||
};
|
||||
const SAVED = { bulkId: "b-1", records: [] };
|
||||
const KEY_1 = "bulk-key-0000000001";
|
||||
const KEY_2 = "bulk-key-0000000002";
|
||||
|
||||
/** What went out to one bulk endpoint: the body and the Idempotency-Key. */
|
||||
function sentTo(path: string) {
|
||||
return apiFetch.mock.calls
|
||||
.filter(([sentPath]) => sentPath === path)
|
||||
.map(([, init]) => ({
|
||||
body: JSON.parse(String(init?.body)) as unknown,
|
||||
key: (init?.headers as Record<string, string>)["Idempotency-Key"],
|
||||
}));
|
||||
}
|
||||
|
||||
async function queuedBulk() {
|
||||
const raw = await openDB("apicore-offline");
|
||||
const entries = await raw.getAll("pending-bulk");
|
||||
raw.close();
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe("bulk entries in syncPendingEntries", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
window.localStorage.clear();
|
||||
vi.resetModules();
|
||||
db = await import("./db");
|
||||
sync = await import("./sync");
|
||||
const apiClient = await import("@/lib/auth/api-client");
|
||||
ApiError = apiClient.ApiError;
|
||||
apiFetch = vi.mocked(apiClient.apiFetch).mockReset();
|
||||
refreshPacksAfterSync = vi.mocked(
|
||||
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
|
||||
);
|
||||
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("drains a bulk entry in the run, sending the key it was queued with", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
await db.queueBulk("varroa", VARROA_BULK, KEY_2, "user-1");
|
||||
apiFetch.mockResolvedValue(SAVED);
|
||||
apiFetch.mockResolvedValue(SAVED);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
|
||||
expect(sentTo("/treatments/bulk")).toEqual([{ body: TREATMENT_BULK, key: KEY_1 }]);
|
||||
expect(sentTo("/varroa-measurements/bulk")).toEqual([{ body: VARROA_BULK, key: KEY_2 }]);
|
||||
expect(await queuedBulk()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a rejected bulk entry with the server answer and reloads the packs of the named colonies only", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
const body = {
|
||||
message: "Einige Völker sind nicht (mehr) erfassbar",
|
||||
invalidColonyIds: ["colony-3"],
|
||||
};
|
||||
apiFetch.mockRejectedValue(new ApiError(400, body.message, body));
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
|
||||
const [kept] = await queuedBulk();
|
||||
expect(kept.idempotencyKey).toBe(KEY_1);
|
||||
expect(kept.lastError).toMatchObject({ status: 400, details: body });
|
||||
expect(refreshPacksAfterSync).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.any(Number),
|
||||
expect.any(Function),
|
||||
{ force: true, colonyIds: ["colony-3"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not force a pack reload for a rejection that names no colony", async () => {
|
||||
await db.queueBulk("feedings", TREATMENT_BULK, KEY_1, "user-1");
|
||||
apiFetch.mockRejectedValue(new ApiError(400, "Menge fehlt"));
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("treats wird bereits verarbeitet as later again: no rejection, no failure", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
apiFetch.mockRejectedValueOnce(
|
||||
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
|
||||
);
|
||||
|
||||
const first = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(first).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
const [kept] = await queuedBulk();
|
||||
expect(kept.lastError).toBeUndefined();
|
||||
|
||||
apiFetch.mockResolvedValue(SAVED);
|
||||
const second = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(second.synced).toBe(1);
|
||||
expect(sentTo("/treatments/bulk").at(-1)).toEqual({ body: TREATMENT_BULK, key: KEY_1 });
|
||||
});
|
||||
|
||||
it("stops at a network failure and leaves the entry untouched", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
apiFetch.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
|
||||
const [kept] = await queuedBulk();
|
||||
expect(kept.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never sends another user's bulk entry", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-2");
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing after cancelSync()", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
await sync.cancelSync();
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("after Ohne diese Voelker neu senden resends the remaining colonies with the same key", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
apiFetch.mockRejectedValueOnce(
|
||||
new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
|
||||
invalidColonyIds: ["colony-2"],
|
||||
}),
|
||||
);
|
||||
await sync.syncPendingEntries("user-1");
|
||||
const [rejected] = await queuedBulk();
|
||||
|
||||
await db.retryBulkWithout(rejected.id, ["colony-2"]);
|
||||
apiFetch.mockResolvedValue(SAVED);
|
||||
const result = await sync.syncPendingEntries("user-1", { afterChange: true });
|
||||
|
||||
expect(result.synced).toBe(1);
|
||||
expect(sentTo("/treatments/bulk").at(-1)).toEqual({
|
||||
body: { ...TREATMENT_BULK, colonyIds: ["colony-1", "colony-3"] },
|
||||
key: KEY_1,
|
||||
});
|
||||
expect(await queuedBulk()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("a session or server problem is not a rejection of the entry", () => {
|
||||
const TREATMENT = { colonyId: "colony-1", date: "2026-10-14T00:00:00.000Z", product: "Oxuvar" };
|
||||
|
||||
async function queuedTreatments() {
|
||||
const raw = await openDB("apicore-offline");
|
||||
const entries = await raw.getAll("pending-treatments");
|
||||
raw.close();
|
||||
return entries;
|
||||
}
|
||||
|
||||
it("a 401 stops the run: bulk entries stay waiting, none is marked rejected", async () => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
await db.queueBulk("varroa", VARROA_BULK, KEY_2, "user-1");
|
||||
apiFetch.mockRejectedValue(new ApiError(401, "Unauthorized"));
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
expect((await queuedBulk()).map((entry) => entry.lastError)).toEqual([undefined, undefined]);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a 401 on a single entry leaves it and the rest of that queue untouched", async () => {
|
||||
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
|
||||
await db.queueTreatment(TREATMENT, "treatment-key-00000002", "user-1");
|
||||
apiFetch.mockRejectedValue(new ApiError(401, "Unauthorized"));
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
expect((await queuedTreatments()).map((entry) => entry.lastError)).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([408, 429, 502, 503, 504])(
|
||||
"a %i leaves a bulk entry waiting and stops like a network failure",
|
||||
async (status) => {
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_1, "user-1");
|
||||
await db.queueBulk("treatments", TREATMENT_BULK, KEY_2, "user-1");
|
||||
apiFetch.mockRejectedValue(new ApiError(status, "Bad Gateway"));
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
|
||||
expect((await queuedBulk()).map((entry) => entry.lastError)).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
|
||||
},
|
||||
);
|
||||
|
||||
it("a 503 leaves a single entry waiting, not rejected", async () => {
|
||||
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
|
||||
apiFetch.mockRejectedValue(new ApiError(503, "Service Unavailable"));
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
const [kept] = await queuedTreatments();
|
||||
expect(kept.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a real rejection (400) is still stored on the entry", async () => {
|
||||
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
|
||||
apiFetch.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet."));
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
const [kept] = await queuedTreatments();
|
||||
expect(kept.lastError).toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it("a single entry whose key is still being processed (409) is skipped, the next one is sent", async () => {
|
||||
await db.queueTreatment(TREATMENT, "treatment-key-00000001", "user-1");
|
||||
await db.queueTreatment(TREATMENT, "treatment-key-00000002", "user-1");
|
||||
apiFetch
|
||||
.mockRejectedValueOnce(
|
||||
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
|
||||
)
|
||||
.mockResolvedValue({ id: "t-2" });
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
const [kept, ...rest] = await queuedTreatments();
|
||||
expect(rest).toEqual([]);
|
||||
expect(kept.idempotencyKey).toBe("treatment-key-00000001");
|
||||
expect(kept.lastError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { openDB } from "idb";
|
||||
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
|
||||
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/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||
|
||||
// What the inspection drain hands over to the photos, seen at that moment.
|
||||
const photoHandOver = vi.hoisted(() => ({
|
||||
@@ -44,6 +46,9 @@ let ApiError: ApiErrorClass;
|
||||
let createInspection: MockedFunction<typeof import("@/lib/inspections/api").createInspection>;
|
||||
let createTreatment: MockedFunction<typeof import("@/lib/treatments/api").createTreatment>;
|
||||
let uploadDocument: MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
|
||||
let refreshPacksAfterSync: MockedFunction<
|
||||
typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync
|
||||
>;
|
||||
|
||||
function inspection(colony: number) {
|
||||
return { colonyId: `colony-${colony}`, date: "2026-10-01T00:00:00.000Z" };
|
||||
@@ -81,6 +86,7 @@ describe("syncPendingEntries", () => {
|
||||
// A fresh database and fresh modules: nothing (no open connection, no
|
||||
// in-flight run) carries over from the previous test.
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
window.localStorage.clear();
|
||||
vi.resetModules();
|
||||
db = await import("./db");
|
||||
sync = await import("./sync");
|
||||
@@ -89,10 +95,14 @@ 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);
|
||||
refreshPacksAfterSync = vi.mocked(
|
||||
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
|
||||
);
|
||||
// The mocks themselves outlive resetModules - start them from scratch too.
|
||||
createInspection.mockReset();
|
||||
createTreatment.mockReset();
|
||||
uploadDocument.mockReset();
|
||||
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
|
||||
photoHandOver.calls.length = 0;
|
||||
photoHandOver.failFor = null;
|
||||
photoHandOver.isQueued = async (key) =>
|
||||
@@ -160,6 +170,81 @@ describe("syncPendingEntries", () => {
|
||||
expect(kept.lastError?.status).toBe(400);
|
||||
});
|
||||
|
||||
it("an inspection for a colony ended since the pack was loaded stays queued with the server message and the packs are refreshed", async () => {
|
||||
// Review Focus 1: the pack still listed V-03 as living when the
|
||||
// inspection was recorded offline; the server knows better.
|
||||
await db.queueInspection(inspection(3), "key-3", "user-1");
|
||||
const body = { statusCode: 400, message: "Volk V-03 ist beendet." };
|
||||
createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet.", body));
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
|
||||
const [kept] = await db.getPendingEntries("pending-inspections");
|
||||
expect(kept.lastError?.message).toBe("Volk V-03 ist beendet.");
|
||||
expect(kept.lastError?.status).toBe(400);
|
||||
// The packs holding V-03 are reloaded at once, not only after 1 h.
|
||||
expect(refreshPacksAfterSync).toHaveBeenCalledTimes(1);
|
||||
expect(refreshPacksAfterSync).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.any(Number),
|
||||
expect.any(Function),
|
||||
{ force: true, colonyIds: ["colony-3"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes the packs by age only when nothing was rejected", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(refreshPacksAfterSync).toHaveBeenCalledTimes(1);
|
||||
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not force a pack reload after a network failure", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
createInspection.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("forces the reload for the colonies of every rejected entry, each once", async () => {
|
||||
await db.queueInspection(inspection(3), "key-3", "user-1");
|
||||
await db.queueInspection(inspection(3), "key-3b", "user-1");
|
||||
await db.queueTreatment(
|
||||
{ colonyId: "colony-5", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" },
|
||||
"key-t",
|
||||
"user-1",
|
||||
);
|
||||
createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet."));
|
||||
createTreatment.mockRejectedValue(new ApiError(400, "Volk V-05 ist beendet."));
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
const options = refreshPacksAfterSync.mock.calls[0][3];
|
||||
expect(options?.force).toBe(true);
|
||||
expect([...(options?.colonyIds ?? [])].sort()).toEqual(["colony-3", "colony-5"]);
|
||||
});
|
||||
|
||||
it("refreshes the packs only for the run's user and never after cancelSync()", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
const request = openRequest();
|
||||
createInspection.mockReturnValueOnce(request.promise);
|
||||
|
||||
const run = sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
|
||||
const cancelled = sync.cancelSync();
|
||||
request.answer({ id: "server-1" });
|
||||
await run;
|
||||
await cancelled;
|
||||
|
||||
expect(refreshPacksAfterSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues with the rest of the queue after a server rejection", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
@@ -212,6 +297,51 @@ describe("syncPendingEntries", () => {
|
||||
expect(createInspection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a caller that changed nothing shares the running run, even once it has begun", async () => {
|
||||
await takePhoto("session-1");
|
||||
await photos.attachSessionToInspection("session-1", "srv-1");
|
||||
const upload = openRequest();
|
||||
uploadDocument.mockReturnValueOnce(upload.promise);
|
||||
const running = sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The band's triggers on a reconnect (`online` event, connectivity check).
|
||||
const reconnect = sync.syncPendingEntries("user-1");
|
||||
upload.answer({ id: "doc-1" });
|
||||
|
||||
expect(reconnect).toBe(running);
|
||||
expect(await running).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(uploadDocument).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs exactly once more for callers that changed something after the run began, so it does not wait", async () => {
|
||||
await takePhoto("session-1");
|
||||
await photos.attachSessionToInspection("session-1", "srv-1");
|
||||
const firstUpload = openRequest();
|
||||
uploadDocument
|
||||
.mockReturnValueOnce(firstUpload.promise)
|
||||
.mockResolvedValue({ id: "doc-2" } as never);
|
||||
const running = sync.syncPendingEntries("user-1");
|
||||
await vi.waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1));
|
||||
|
||||
// An online save while the running sync uploads: its photo is ready only now.
|
||||
await takePhoto("session-2");
|
||||
await photos.attachSessionToInspection("session-2", "srv-2");
|
||||
const afterSave = sync.syncPendingEntries("user-1", { afterChange: true });
|
||||
// e.g. „Erneut senden“
|
||||
const afterRetry = sync.syncPendingEntries("user-1", { afterChange: true });
|
||||
firstUpload.answer({ id: "doc-1" });
|
||||
|
||||
expect(await running).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(afterRetry).toBe(afterSave);
|
||||
expect(await afterSave).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(uploadDocument.mock.calls.map(([, inspectionId]) => inspectionId)).toEqual([
|
||||
"srv-1",
|
||||
"srv-2",
|
||||
]);
|
||||
expect(await photos.listSessionPhotos("session-2")).toEqual([]);
|
||||
});
|
||||
|
||||
it("never sends a user's entries after cancelSync() during a running sync", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
@@ -370,6 +500,34 @@ describe("syncPendingEntries", () => {
|
||||
expect(await photos.listSessionPhotos("session-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("finishes the user's open photo hand-overs before uploading, never another user's", async () => {
|
||||
const own = await takePhoto("session-1");
|
||||
await photos.addDraftPhoto({
|
||||
sessionId: "session-2",
|
||||
ownerUserId: "user-2",
|
||||
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
|
||||
date: "2026-10-01",
|
||||
});
|
||||
// Saved online, but the photos could not be tied to the inspection then.
|
||||
rememberHandOver({ userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" });
|
||||
rememberHandOver({ userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" });
|
||||
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(uploadDocument).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDocument).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.anything(), {
|
||||
idempotencyKey: own.uploadIdempotencyKey,
|
||||
fileName: own.fileName,
|
||||
});
|
||||
expect(readHandOvers()).toEqual([
|
||||
{ userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" },
|
||||
]);
|
||||
const [foreign] = await photos.listSessionPhotos("session-2");
|
||||
expect(foreign.state).toBe("draft");
|
||||
});
|
||||
|
||||
it("keeps an inspection whose photo hand-over failed and still finishes the run", async () => {
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
await db.queueInspection(inspection(2), "key-2", "user-1");
|
||||
|
||||
@@ -7,12 +7,25 @@ import { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
|
||||
import { createHarvest } from "@/lib/harvests/api";
|
||||
import { uploadDocument } from "@/lib/documents/api";
|
||||
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
|
||||
import { resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
|
||||
import {
|
||||
createFeedingsBulk,
|
||||
createTreatmentsBulk,
|
||||
createVarroaBulk,
|
||||
invalidColonyIdsOf,
|
||||
isRetryLaterError,
|
||||
} from "@/lib/bulk/api";
|
||||
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
||||
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
|
||||
import { finishHandOvers, resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
|
||||
import {
|
||||
BulkKind,
|
||||
PendingBulkEntry,
|
||||
PendingEntry,
|
||||
QueueStoreName,
|
||||
RecordQueueInputs,
|
||||
RecordStoreName,
|
||||
getBulkEntries,
|
||||
getBulkEntry,
|
||||
getPendingEntries,
|
||||
getPendingEntry,
|
||||
isForeignEntry,
|
||||
@@ -28,15 +41,38 @@ export interface SyncResult {
|
||||
skippedForeign: number;
|
||||
}
|
||||
|
||||
export interface SyncRequest {
|
||||
/** The caller has just made something sendable that a running run may have passed. */
|
||||
afterChange?: boolean;
|
||||
}
|
||||
|
||||
/** One sync run: whose entries it sends, and whether it was cancelled since. */
|
||||
interface SyncRun {
|
||||
userId: string;
|
||||
isCancelled: () => boolean;
|
||||
}
|
||||
|
||||
const UNAUTHORIZED = 401;
|
||||
/** Answers that say „not now“, not „this entry is wrong“. */
|
||||
const TRANSIENT_STATUSES: readonly number[] = [408, 429, 502, 503, 504];
|
||||
|
||||
const NOTHING_SYNCED: SyncResult = { synced: 0, failed: 0, skippedForeign: 0 };
|
||||
|
||||
let inFlight: { userId: string; generation: number; run: Promise<SyncResult> } | null = null;
|
||||
/** One queue's drain: its counts and the colonies the server rejected entries for. */
|
||||
interface DrainResult extends SyncResult {
|
||||
rejectedColonyIds: string[];
|
||||
}
|
||||
|
||||
/** A requested run; runs are chained, so this is the last one in line. */
|
||||
interface ScheduledRun {
|
||||
userId: string;
|
||||
generation: number;
|
||||
run: Promise<SyncResult>;
|
||||
/** True once it reads the queues: what changes after that, it may miss. */
|
||||
hasStarted: () => boolean;
|
||||
}
|
||||
|
||||
let lastScheduled: ScheduledRun | null = null;
|
||||
// Raised by cancelSync(): every run requested before stops sending.
|
||||
let generation = 0;
|
||||
// Closed by cancelSync() until resumeSync(): between a logout and the next
|
||||
@@ -49,32 +85,53 @@ let isOpen = true;
|
||||
* different record types before connectivity returns. Only entries of
|
||||
* `currentUserId` (and ownerless ones from before owners existed) are sent.
|
||||
*
|
||||
* Callers for the same user share one run; a run for another user starts
|
||||
* once the current one is done, so two accounts never send at the same time.
|
||||
* Every drain runs inside such a run, so cancelSync() always reaches it.
|
||||
* Callers for the same user share one run (a reconnect fires several
|
||||
* triggers at once). A caller that has just changed what is to be sent (a
|
||||
* photo made ready, a rejection reset) says so with `afterChange`: if the
|
||||
* run has already begun, it may have passed that change, so the caller gets
|
||||
* one more run after it - every further caller shares that one until it
|
||||
* begins. A run for another user starts once the current one is done, so two
|
||||
* accounts never send at the same time. Every drain runs inside such a run,
|
||||
* so cancelSync() always reaches it.
|
||||
*/
|
||||
export function syncPendingEntries(currentUserId: string): Promise<SyncResult> {
|
||||
export function syncPendingEntries(
|
||||
currentUserId: string,
|
||||
{ afterChange = false }: SyncRequest = {},
|
||||
): Promise<SyncResult> {
|
||||
if (!isOpen) {
|
||||
return Promise.resolve(NOTHING_SYNCED);
|
||||
}
|
||||
if (inFlight?.userId === currentUserId && inFlight.generation === generation) {
|
||||
return inFlight.run;
|
||||
if (
|
||||
lastScheduled?.userId === currentUserId &&
|
||||
lastScheduled.generation === generation &&
|
||||
!(afterChange && lastScheduled.hasStarted())
|
||||
) {
|
||||
return lastScheduled.run;
|
||||
}
|
||||
const requestedIn = generation;
|
||||
const syncRun: SyncRun = {
|
||||
userId: currentUserId,
|
||||
isCancelled: () => generation !== requestedIn,
|
||||
};
|
||||
const previous = inFlight?.run ?? Promise.resolve(NOTHING_SYNCED);
|
||||
let started = false;
|
||||
const previous = lastScheduled?.run ?? Promise.resolve(NOTHING_SYNCED);
|
||||
const run = previous
|
||||
.catch(() => NOTHING_SYNCED)
|
||||
.then(() => runSync(syncRun))
|
||||
.then(() => {
|
||||
started = true;
|
||||
return runSync(syncRun);
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight?.run === run) {
|
||||
inFlight = null;
|
||||
if (lastScheduled?.run === run) {
|
||||
lastScheduled = null;
|
||||
}
|
||||
});
|
||||
inFlight = { userId: currentUserId, generation: requestedIn, run };
|
||||
lastScheduled = {
|
||||
userId: currentUserId,
|
||||
generation: requestedIn,
|
||||
run,
|
||||
hasStarted: () => started,
|
||||
};
|
||||
return run;
|
||||
}
|
||||
|
||||
@@ -87,7 +144,8 @@ export function syncPendingEntries(currentUserId: string): Promise<SyncResult> {
|
||||
export async function cancelSync(): Promise<void> {
|
||||
isOpen = false;
|
||||
generation += 1;
|
||||
await inFlight?.run.catch(() => undefined);
|
||||
// Runs are chained: once the last one is done, all are.
|
||||
await lastScheduled?.run.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,15 +181,51 @@ async function drain<Store extends RecordStoreName, Result>(
|
||||
create: (input: RecordQueueInputs[Store], options: CreateOptions) => Promise<Result>,
|
||||
run: SyncRun,
|
||||
onCreated?: (entry: PendingEntry<RecordQueueInputs[Store]>, result: Result) => Promise<void>,
|
||||
): Promise<SyncResult> {
|
||||
const queued = await getPendingEntries(store);
|
||||
): Promise<DrainResult> {
|
||||
return drainQueue<PendingEntry<RecordQueueInputs[Store]>, Result>({
|
||||
store,
|
||||
list: () => getPendingEntries(store),
|
||||
read: (id) => getPendingEntry(store, id),
|
||||
send: (entry) => create(entry.input, { idempotencyKey: entry.idempotencyKey }),
|
||||
rejectedColonyIds: (entry) => {
|
||||
const colonyId = colonyIdOf(entry.input);
|
||||
return colonyId ? [colonyId] : [];
|
||||
},
|
||||
onCreated,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the drain needs to know about one queue; record queues and bulk differ only here. */
|
||||
interface QueueDrain<Entry extends { id: number; ownerUserId?: string }, Result> {
|
||||
store: QueueStoreName;
|
||||
list: () => Promise<Entry[]>;
|
||||
read: (id: number) => Promise<Entry | undefined>;
|
||||
send: (entry: Entry) => Promise<Result>;
|
||||
/** The colonies the server refused, so their packs are reloaded at once. */
|
||||
rejectedColonyIds: (entry: Entry, error: ApiError) => string[];
|
||||
onCreated?: (entry: Entry, result: Result) => Promise<void>;
|
||||
run: SyncRun;
|
||||
}
|
||||
|
||||
async function drainQueue<Entry extends { id: number; ownerUserId?: string }, Result>({
|
||||
store,
|
||||
list,
|
||||
read,
|
||||
send,
|
||||
rejectedColonyIds: rejectedColonies,
|
||||
onCreated,
|
||||
run,
|
||||
}: QueueDrain<Entry, Result>): Promise<DrainResult> {
|
||||
const queued = await list();
|
||||
const ownIds = queued.filter((entry) => !isForeignEntry(entry, run.userId)).map(({ id }) => id);
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
let skippedForeign = queued.length - ownIds.length;
|
||||
const rejectedColonyIds: string[] = [];
|
||||
|
||||
for (const id of ownIds) {
|
||||
const entry = await getPendingEntry(store, id);
|
||||
const entry = await read(id);
|
||||
if (run.isCancelled()) {
|
||||
break;
|
||||
}
|
||||
@@ -144,13 +238,26 @@ async function drain<Store extends RecordStoreName, Result>(
|
||||
}
|
||||
let result: Result;
|
||||
try {
|
||||
result = await create(entry.input, { idempotencyKey: entry.idempotencyKey });
|
||||
result = await send(entry);
|
||||
} catch (error) {
|
||||
// The same key is still being processed (409): neither sent nor
|
||||
// rejected, the next run asks again.
|
||||
if (isRetryLaterError(error)) {
|
||||
continue;
|
||||
}
|
||||
// The session ended (401): the entry is fine. The run stops and the
|
||||
// auth layer deals with the expired session.
|
||||
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
|
||||
break;
|
||||
}
|
||||
failed += 1;
|
||||
if (!(error instanceof ApiError)) {
|
||||
// A network failure or a server that is only unavailable for now
|
||||
// (timeout, overload, proxy errors): the entry waits, the run stops.
|
||||
if (!(error instanceof ApiError) || TRANSIENT_STATUSES.includes(error.status)) {
|
||||
break;
|
||||
}
|
||||
await markFailed(store, entry.id, toQueueError(error));
|
||||
rejectedColonyIds.push(...rejectedColonies(entry, error));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -163,7 +270,33 @@ async function drain<Store extends RecordStoreName, Result>(
|
||||
synced += 1;
|
||||
}
|
||||
|
||||
return { synced, failed, skippedForeign };
|
||||
return { synced, failed, skippedForeign, rejectedColonyIds };
|
||||
}
|
||||
|
||||
const BULK_CREATORS: Record<
|
||||
BulkKind,
|
||||
(input: never, options: CreateOptions) => Promise<unknown>
|
||||
> = {
|
||||
treatments: createTreatmentsBulk,
|
||||
feedings: createFeedingsBulk,
|
||||
varroa: createVarroaBulk,
|
||||
};
|
||||
|
||||
/**
|
||||
* The entries for several colonies. Each goes out as one request with the key
|
||||
* it was queued with; a 400 names the colonies the server refused
|
||||
* (`invalidColonyIds`), whose packs are then reloaded at once.
|
||||
*/
|
||||
function drainBulk(run: SyncRun): Promise<DrainResult> {
|
||||
return drainQueue<PendingBulkEntry, unknown>({
|
||||
store: "pending-bulk",
|
||||
list: getBulkEntries,
|
||||
read: getBulkEntry,
|
||||
send: (entry) =>
|
||||
BULK_CREATORS[entry.kind](entry.input as never, { idempotencyKey: entry.idempotencyKey }),
|
||||
rejectedColonyIds: (_entry, error) => invalidColonyIdsOf(error),
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +314,12 @@ function handOverPhotos(
|
||||
return resolveQueuedInspection(entry.idempotencyKey, created.id);
|
||||
}
|
||||
|
||||
/** The colony a queued record belongs to, if it has one. */
|
||||
function colonyIdOf(input: unknown): string | null {
|
||||
const colonyId = (input as { colonyId?: unknown } | null)?.colonyId;
|
||||
return typeof colonyId === "string" ? colonyId : null;
|
||||
}
|
||||
|
||||
function addResults(total: SyncResult, result: SyncResult): SyncResult {
|
||||
return {
|
||||
synced: total.synced + result.synced,
|
||||
@@ -197,9 +336,24 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
|
||||
drain("pending-varroa-measurements", createVarroaMeasurement, run),
|
||||
drain("pending-swarm-control-checks", createSwarmControlCheck, run),
|
||||
drain("pending-harvests", createHarvest, run),
|
||||
drainBulk(run),
|
||||
]);
|
||||
// Photos last: the drains above have just given them their inspection's
|
||||
// server id, and records come first on a weak connection.
|
||||
// server id, and records come first on a weak connection. Hand-overs that
|
||||
// failed after an online save are finished first, so those go up too.
|
||||
if (!run.isCancelled()) {
|
||||
await finishHandOvers(run.userId);
|
||||
}
|
||||
const photos = await uploadReadyPhotos(run.userId, uploadDocument, run.isCancelled);
|
||||
// Packs last, quietly and only with the run user's token: what the server
|
||||
// has just accepted or rejected (a colony ended since the pack was loaded)
|
||||
// shows up offline too - the packs of rejected colonies at once, not only
|
||||
// after 1 h (Review Focus 1). Inside the run, so cancelSync() waits for it.
|
||||
if (!run.isCancelled()) {
|
||||
const colonyIds = [...new Set(records.flatMap((record) => record.rejectedColonyIds))];
|
||||
await (colonyIds.length > 0
|
||||
? refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled, { force: true, colonyIds })
|
||||
: refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled));
|
||||
}
|
||||
return [...records, photos].reduce(addResults, NOTHING_SYNCED);
|
||||
}
|
||||
@@ -152,3 +152,69 @@ describe("useCaptureMutation", () => {
|
||||
expect(result.current.submitError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCaptureMutation with a key per submission", () => {
|
||||
function setupPerSubmission() {
|
||||
const create = vi.fn<(input: Input, options: { idempotencyKey?: string }) => Promise<Saved>>(
|
||||
async (input) => ({ id: "i-1", ...input }),
|
||||
); const queue = vi.fn().mockResolvedValue(undefined);
|
||||
const client = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCaptureMutation<Input, Saved>({
|
||||
create,
|
||||
queue,
|
||||
failureMessage: FAILURE,
|
||||
onSaved: vi.fn(),
|
||||
keyPerSubmission: true,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
return { result, create };
|
||||
}
|
||||
|
||||
const keyOfCall = (create: ReturnType<typeof setupPerSubmission>["create"], index: number) =>
|
||||
create.mock.calls[index][1].idempotencyKey;
|
||||
|
||||
it("repeats the key for the same submission and renews it for a different one", async () => {
|
||||
const { result, create } = setupPerSubmission();
|
||||
|
||||
act(() => result.current.submit({ colonyId: "colony-1" }));
|
||||
await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||
act(() => result.current.submit({ colonyId: "colony-1" }));
|
||||
await waitFor(() => expect(create).toHaveBeenCalledTimes(2));
|
||||
act(() => result.current.submit({ colonyId: "colony-2" }));
|
||||
await waitFor(() => expect(create).toHaveBeenCalledTimes(3));
|
||||
|
||||
expect(keyOfCall(create, 0)).toBeTruthy();
|
||||
expect(keyOfCall(create, 1)).toBe(keyOfCall(create, 0));
|
||||
expect(keyOfCall(create, 2)).not.toBe(keyOfCall(create, 0));
|
||||
});
|
||||
|
||||
it("exposes the error of the last submit", async () => {
|
||||
const error = new ApiError(409, "Diese Anfrage wird bereits verarbeitet");
|
||||
const create = vi.fn().mockRejectedValue(error);
|
||||
const client = new QueryClient();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCaptureMutation<Input, Saved>({
|
||||
create,
|
||||
queue: vi.fn(),
|
||||
failureMessage: FAILURE,
|
||||
onSaved: vi.fn(),
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => result.current.submit(INPUT));
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe(error));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
@@ -20,12 +20,21 @@ export interface CaptureMutationOptions<I, R> {
|
||||
/** Shown when nothing could be saved and the server gave no reason. */
|
||||
failureMessage: string;
|
||||
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
|
||||
/**
|
||||
* One key per distinct submission instead of one per opened form: the server
|
||||
* binds a key to (user, key, scope), not to the body, so an entry for
|
||||
* another selection of colonies must never reuse the key of an earlier one.
|
||||
* The same input again (double tap, retry) still sends the same key.
|
||||
*/
|
||||
keyPerSubmission?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptureMutation<I> {
|
||||
submit: (input: I) => void;
|
||||
isPending: boolean;
|
||||
submitError: string | null;
|
||||
/** What the last submit failed with (an ApiError, or a storage failure), if it did. */
|
||||
error: unknown;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
@@ -39,12 +48,25 @@ export function useCaptureMutation<I, R>({
|
||||
queue,
|
||||
failureMessage,
|
||||
onSaved,
|
||||
keyPerSubmission = false,
|
||||
}: CaptureMutationOptions<I, R>): CaptureMutation<I> {
|
||||
const ownerUserId = useCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
// One key per opened form: a double tap or an offline retry of the
|
||||
// same entry is recognised by the server instead of saved twice.
|
||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||
const lastSubmission = useRef<{ fingerprint: string; key: string } | null>(null);
|
||||
|
||||
function keyFor(input: I): string {
|
||||
if (!keyPerSubmission) {
|
||||
return idempotencyKey;
|
||||
}
|
||||
const fingerprint = JSON.stringify(input);
|
||||
if (lastSubmission.current?.fingerprint !== fingerprint) {
|
||||
lastSubmission.current = { fingerprint, key: newIdempotencyKey() };
|
||||
}
|
||||
return lastSubmission.current.key;
|
||||
}
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -53,17 +75,18 @@ export function useCaptureMutation<I, R>({
|
||||
networkMode: "always",
|
||||
retry: false,
|
||||
mutationFn: async (input: I): Promise<CaptureOutcome<R>> => {
|
||||
const key = keyFor(input);
|
||||
try {
|
||||
return { via: "online", result: await create(input, { idempotencyKey }) };
|
||||
return { via: "online", result: await create(input, { idempotencyKey: key }) };
|
||||
} catch (error) {
|
||||
// Never queue without an owner: such an entry would be sent under
|
||||
// whoever signs in next on this device.
|
||||
if (error instanceof ApiError || !ownerUserId) {
|
||||
throw error;
|
||||
}
|
||||
await queue(input, idempotencyKey, ownerUserId);
|
||||
await queue(input, key, ownerUserId);
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
return { via: "queued", idempotencyKey };
|
||||
return { via: "queued", idempotencyKey: key };
|
||||
}
|
||||
},
|
||||
onSuccess: (outcome, input) => onSaved(outcome, input),
|
||||
@@ -75,5 +98,11 @@ export function useCaptureMutation<I, R>({
|
||||
mutation.mutate(input);
|
||||
}
|
||||
|
||||
return { submit, isPending: mutation.isPending, submitError, idempotencyKey };
|
||||
return {
|
||||
submit,
|
||||
isPending: mutation.isPending,
|
||||
submitError,
|
||||
error: mutation.error,
|
||||
idempotencyKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import {
|
||||
HANDOVER_MAX_AGE_MS,
|
||||
forgetHandOver,
|
||||
forgetHandOversExcept,
|
||||
isHandOverExpired,
|
||||
readHandOverRecords,
|
||||
readHandOvers,
|
||||
rememberHandOver,
|
||||
} from "./handover";
|
||||
|
||||
const OWN = { userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" };
|
||||
const OTHER = { userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" };
|
||||
|
||||
describe("photo hand-overs", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("remembers an unfinished hand-over until it is forgotten", () => {
|
||||
rememberHandOver(OWN);
|
||||
rememberHandOver(OTHER);
|
||||
|
||||
expect(readHandOvers()).toHaveLength(2);
|
||||
expect(readHandOvers()).toEqual(expect.arrayContaining([OWN, OTHER]));
|
||||
|
||||
forgetHandOver("session-1");
|
||||
|
||||
expect(readHandOvers()).toEqual([OTHER]);
|
||||
});
|
||||
|
||||
it("outlives a logout: the photos still belong to that user's saved inspection", () => {
|
||||
rememberHandOver(OWN);
|
||||
|
||||
clearAllFormDrafts();
|
||||
|
||||
expect(readHandOvers()).toEqual([OWN]);
|
||||
});
|
||||
|
||||
it("skips what it cannot read and never throws without storage", () => {
|
||||
window.localStorage.setItem("apicore:photo-handover:broken", "{not json");
|
||||
window.localStorage.setItem(
|
||||
"apicore:photo-handover:partial",
|
||||
JSON.stringify({ userId: "user-1", sessionId: "partial" }),
|
||||
);
|
||||
|
||||
expect(readHandOvers()).toEqual([]);
|
||||
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
expect(() => rememberHandOver(OWN)).not.toThrow();
|
||||
});
|
||||
|
||||
it("notes when a hand-over began and lets it expire after the draft lifetime", () => {
|
||||
rememberHandOver(OWN, 1_000);
|
||||
|
||||
const [record] = readHandOverRecords();
|
||||
expect(record).toEqual({ ...OWN, createdAt: 1_000 });
|
||||
expect(isHandOverExpired(record, 1_000 + HANDOVER_MAX_AGE_MS)).toBe(false);
|
||||
expect(isHandOverExpired(record, 1_000 + HANDOVER_MAX_AGE_MS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a hand-over without a date as expired", () => {
|
||||
window.localStorage.setItem("apicore:photo-handover:old", JSON.stringify({ ...OWN, sessionId: "old" }));
|
||||
|
||||
const [record] = readHandOverRecords();
|
||||
expect(record.createdAt).toBe(0);
|
||||
expect(isHandOverExpired(record, Date.now())).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the hand-overs of every other user, or all of them", () => {
|
||||
rememberHandOver(OWN);
|
||||
rememberHandOver(OTHER);
|
||||
|
||||
forgetHandOversExcept("user-1");
|
||||
expect(readHandOvers()).toEqual([OWN]);
|
||||
|
||||
forgetHandOversExcept(null);
|
||||
expect(readHandOvers()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* An online save whose photos could not be tied to the saved inspection at
|
||||
* once (the device's storage failed for a moment) leaves a hand-over here.
|
||||
* It lives outside IndexedDB on purpose: it must survive the very storage
|
||||
* failure it is about. The photo store finishes it at the next start or sync
|
||||
* of the same user and keeps its photos from being purged meanwhile.
|
||||
*/
|
||||
|
||||
import { DRAFT_MAX_AGE_MS } from "@/lib/forms/draft-storage";
|
||||
|
||||
const PREFIX = "apicore:photo-handover:";
|
||||
|
||||
/**
|
||||
* A hand-over that is still open after this long is given up: its photos are
|
||||
* then purged like any other draft, and the note goes with them.
|
||||
*/
|
||||
export const HANDOVER_MAX_AGE_MS = DRAFT_MAX_AGE_MS;
|
||||
|
||||
export interface PhotoHandOver {
|
||||
/** The signed-in user who saved: only their own sync finishes it. */
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
/** The saved inspection's server id. */
|
||||
inspectionId: string;
|
||||
}
|
||||
|
||||
/** A hand-over as noted on this device; `createdAt` is epoch milliseconds. */
|
||||
export interface HandOverRecord extends PhotoHandOver {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function isHandOver(value: unknown): value is PhotoHandOver {
|
||||
const candidate = value as Partial<PhotoHandOver> | null;
|
||||
return (
|
||||
typeof candidate?.userId === "string" &&
|
||||
typeof candidate.sessionId === "string" &&
|
||||
typeof candidate.inspectionId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function parse(raw: string | null): HandOverRecord[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw ?? "null");
|
||||
if (!isHandOver(value)) {
|
||||
return [];
|
||||
}
|
||||
const { userId, sessionId, inspectionId } = value;
|
||||
const createdAt = (value as { createdAt?: unknown }).createdAt;
|
||||
// A note without a date comes from before dates existed: long expired.
|
||||
return [{ userId, sessionId, inspectionId, createdAt: typeof createdAt === "number" ? createdAt : 0 }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberHandOver(handOver: PhotoHandOver, now: number = Date.now()): void {
|
||||
try {
|
||||
const { userId, sessionId, inspectionId } = handOver;
|
||||
window.localStorage.setItem(
|
||||
PREFIX + sessionId,
|
||||
JSON.stringify({ userId, sessionId, inspectionId, createdAt: now }),
|
||||
);
|
||||
} catch {
|
||||
// Storage full or blocked: the hand-over itself is still tried.
|
||||
}
|
||||
}
|
||||
|
||||
export function forgetHandOver(sessionId: string): void {
|
||||
try {
|
||||
window.localStorage.removeItem(PREFIX + sessionId);
|
||||
} catch {
|
||||
// Storage unavailable - nothing stored either.
|
||||
}
|
||||
}
|
||||
|
||||
export function isHandOverExpired(handOver: { createdAt?: number }, now: number): boolean {
|
||||
return typeof handOver.createdAt === "number" && now - handOver.createdAt > HANDOVER_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
/** Every unfinished hand-over on this device, of every user, with its date. */
|
||||
export function readHandOverRecords(): HandOverRecord[] {
|
||||
try {
|
||||
const handOvers: HandOverRecord[] = [];
|
||||
for (let index = 0; index < window.localStorage.length; index++) {
|
||||
const storageKey = window.localStorage.key(index);
|
||||
if (storageKey?.startsWith(PREFIX)) {
|
||||
handOvers.push(...parse(window.localStorage.getItem(storageKey)));
|
||||
}
|
||||
}
|
||||
return handOvers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Every unfinished hand-over on this device, of every user. */
|
||||
export function readHandOvers(): PhotoHandOver[] {
|
||||
return readHandOverRecords().map(({ userId, sessionId, inspectionId }) => ({
|
||||
userId,
|
||||
sessionId,
|
||||
inspectionId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-out and user switch: a hand-over belongs to the user who saved, and
|
||||
* their photos are not handed over for anyone else. `null` removes all.
|
||||
*/
|
||||
export function forgetHandOversExcept(userId: string | null): void {
|
||||
readHandOverRecords()
|
||||
.filter((handOver) => handOver.userId !== userId)
|
||||
.forEach((handOver) => forgetHandOver(handOver.sessionId));
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
// @vitest-environment node
|
||||
// Node's Blob: its bytes can be read back (jsdom's Blob cannot be cloned).
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
PHOTO_FALLBACK_QUALITY,
|
||||
PHOTO_JPEG_QUALITY,
|
||||
PHOTO_MAX_EDGE_PX,
|
||||
PhotoMetadataError,
|
||||
PhotoTooLargeError,
|
||||
canvasImageEncoder,
|
||||
containsExifOrGps,
|
||||
preparePhoto,
|
||||
type ImageEncoder,
|
||||
@@ -119,3 +120,57 @@ describe("preparePhoto", () => {
|
||||
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoMetadataError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvasImageEncoder", () => {
|
||||
const camera = new Blob([jpeg(EXIF_APP1, QUANTIZATION, SCAN)], { type: "image/jpeg" });
|
||||
|
||||
/** A decoded 4000 × 3000 photo and a canvas that writes CLEAN_JPEG. */
|
||||
function stubBrowser(createImageBitmap: ReturnType<typeof vi.fn>) {
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({ fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() }),
|
||||
toBlob: (done: (blob: Blob) => void) => done(new Blob([CLEAN_JPEG], { type: "image/jpeg" })),
|
||||
};
|
||||
vi.stubGlobal("createImageBitmap", createImageBitmap);
|
||||
vi.stubGlobal("document", { createElement: () => canvas });
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function bitmap() {
|
||||
return { width: 4000, height: 3000, close: vi.fn() };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("decodes again without options when the engine rejects imageOrientation", async () => {
|
||||
const decoded = bitmap();
|
||||
// Older engines know no "from-image" and throw a TypeError for it.
|
||||
const decode = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("Failed to read the 'imageOrientation' property"))
|
||||
.mockResolvedValueOnce(decoded);
|
||||
const canvas = stubBrowser(decode);
|
||||
|
||||
const encoded = await canvasImageEncoder.encode(camera, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY);
|
||||
|
||||
expect(decode).toHaveBeenNthCalledWith(1, camera, { imageOrientation: "from-image" });
|
||||
expect(decode).toHaveBeenNthCalledWith(2, camera);
|
||||
expect(new Uint8Array(await encoded.arrayBuffer())).toEqual(CLEAN_JPEG);
|
||||
expect([canvas.width, canvas.height]).toEqual([2560, 1920]);
|
||||
expect(decoded.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry when the image itself cannot be decoded", async () => {
|
||||
const broken = new Error("The source image could not be decoded.");
|
||||
const decode = vi.fn().mockRejectedValue(broken);
|
||||
stubBrowser(decode);
|
||||
|
||||
await expect(
|
||||
canvasImageEncoder.encode(camera, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY),
|
||||
).rejects.toBe(broken);
|
||||
expect(decode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -26,16 +26,31 @@ export class PhotoMetadataError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the photo with the camera's rotation applied to the pixels, so it
|
||||
* still stands upright once the orientation tag is gone. Older engines know
|
||||
* no `imageOrientation: "from-image"` and throw a TypeError for the option:
|
||||
* they decode it without the option instead of failing every photo.
|
||||
*/
|
||||
async function decodeUpright(file: Blob): Promise<ImageBitmap> {
|
||||
try {
|
||||
return await createImageBitmap(file, { imageOrientation: "from-image" });
|
||||
} catch (error) {
|
||||
if (!(error instanceof TypeError)) {
|
||||
throw error;
|
||||
}
|
||||
return createImageBitmap(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the image onto a canvas and writes a new JPEG. A canvas holds pixels
|
||||
* only, so nothing of the camera's EXIF (GPS position of the apiary, device,
|
||||
* time) or XMP can reach the output. The camera's rotation is applied to the
|
||||
* pixels first (`imageOrientation: "from-image"`), so the photo still stands
|
||||
* upright once the orientation tag is gone.
|
||||
* time) or XMP can reach the output.
|
||||
*/
|
||||
export const canvasImageEncoder: ImageEncoder = {
|
||||
async encode(file, maxEdgePx, quality) {
|
||||
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
||||
const bitmap = await decodeUpright(file);
|
||||
try {
|
||||
const scale = Math.min(1, maxEdgePx / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
|
||||
import { readHandOvers, rememberHandOver } from "./handover";
|
||||
|
||||
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
|
||||
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
|
||||
@@ -45,6 +46,21 @@ function uploadMock(): Upload {
|
||||
return vi.fn() as Upload;
|
||||
}
|
||||
|
||||
/** localStorage for the node environment (hand-overs live there). */
|
||||
function memoryStorage(): Storage {
|
||||
const items = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return items.size;
|
||||
},
|
||||
key: (index: number) => [...items.keys()][index] ?? null,
|
||||
getItem: (key: string) => items.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void items.set(key, String(value)),
|
||||
removeItem: (key: string) => void items.delete(key),
|
||||
clear: () => items.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("photo store", () => {
|
||||
beforeEach(async () => {
|
||||
// Fresh database and modules: no open connection survives from the last test.
|
||||
@@ -62,6 +78,7 @@ describe("photo store", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("draft → waiting → ready after the queued inspection is created", async () => {
|
||||
@@ -254,16 +271,148 @@ describe("photo store", () => {
|
||||
expect(photo.inspectionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives the photos back to the form when the inspection could not be queued", async () => {
|
||||
it("queues the inspection and ties the form's photos to it in one step", async () => {
|
||||
await takePhoto();
|
||||
await store.attachSessionToQueuedInspection("session-1", "key-a");
|
||||
await takePhoto();
|
||||
// Something the device's storage refuses to keep, e.g. when it is full.
|
||||
const unstorable = { ...INSPECTION, notes: () => "kein Wert" } as never;
|
||||
|
||||
await store.releaseSessionPhotos("session-1");
|
||||
await expect(
|
||||
store.queueInspectionWithPhotos("session-1", unstorable, "key-a", "user-1"),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Nothing was queued, so the photos still belong to the open form.
|
||||
expect(await db.listQueue("user-1")).toEqual([]);
|
||||
const kept = await store.listSessionPhotos("session-1");
|
||||
expect(kept.map((photo) => [photo.state, photo.inspectionIdempotencyKey])).toEqual([
|
||||
["draft", undefined],
|
||||
["draft", undefined],
|
||||
]);
|
||||
|
||||
expect(await store.queueInspectionWithPhotos("session-1", INSPECTION, "key-a", "user-1")).toBe(
|
||||
2,
|
||||
);
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([
|
||||
expect.objectContaining({ input: INSPECTION, idempotencyKey: "key-a", ownerUserId: "user-1" }),
|
||||
]);
|
||||
const [queued] = await db.listQueue("user-1");
|
||||
expect(queued.photoCount).toBe(2);
|
||||
const waiting = await store.listSessionPhotos("session-1");
|
||||
expect(waiting.map((photo) => [photo.state, photo.inspectionIdempotencyKey])).toEqual([
|
||||
["waiting-for-inspection", "key-a"],
|
||||
["waiting-for-inspection", "key-a"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("tells how many photos it handed to the saved inspection", async () => {
|
||||
expect(await store.attachSessionToInspection("session-1", "srv-1")).toBe(0);
|
||||
await takePhoto();
|
||||
await takePhoto();
|
||||
|
||||
expect(await store.attachSessionToInspection("session-1", "srv-1")).toBe(2);
|
||||
// Already handed over: nothing moves twice.
|
||||
expect(await store.attachSessionToInspection("session-1", "srv-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("moves a fresh form's photos into a restored draft up to the limit and discards the rest", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-12T08:00:00.000Z"));
|
||||
for (let count = 0; count < 3; count++) await takePhoto("restored");
|
||||
vi.setSystemTime(new Date("2026-10-13T08:00:00.000Z"));
|
||||
const fresh = [await takePhoto("fresh"), await takePhoto("fresh"), await takePhoto("fresh")];
|
||||
|
||||
const result = await store.moveDraftPhotos("fresh", "restored");
|
||||
|
||||
expect(result).toEqual({ moved: 2, discarded: 1 });
|
||||
const restored = await store.listSessionPhotos("restored");
|
||||
expect(restored.map((photo) => photo.fileName)).toEqual([
|
||||
"durchsicht-2026-10-13-1.jpg",
|
||||
"durchsicht-2026-10-13-2.jpg",
|
||||
"durchsicht-2026-10-13-3.jpg",
|
||||
"durchsicht-2026-10-13-4.jpg",
|
||||
"durchsicht-2026-10-13-5.jpg",
|
||||
]);
|
||||
expect(restored.slice(3).map((photo) => photo.id)).toEqual([fresh[0].id, fresh[1].id]);
|
||||
expect(restored.every((photo) => photo.state === "draft")).toBe(true);
|
||||
// The one that did not fit is deleted openly (the form says so), not left unreachable.
|
||||
expect(await store.listSessionPhotos("fresh")).toEqual([]);
|
||||
});
|
||||
|
||||
it("hands the photos to the saved inspection under a hand-over that ends once they belong to it", async () => {
|
||||
vi.stubGlobal("window", { localStorage: memoryStorage() });
|
||||
await takePhoto("session-1");
|
||||
await takePhoto("session-2");
|
||||
const handOver = { userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" };
|
||||
|
||||
expect(await store.handOverSessionPhotos(handOver)).toBe(1);
|
||||
|
||||
expect(readHandOvers()).toEqual([]);
|
||||
const [photo] = await store.listSessionPhotos("session-1");
|
||||
expect(photo.state).toBe("draft");
|
||||
expect(photo.inspectionIdempotencyKey).toBeUndefined();
|
||||
expect((await db.listQueue("user-1")).length).toBe(0);
|
||||
expect(photo).toMatchObject({ state: "ready", inspectionId: "srv-1" });
|
||||
|
||||
// The device's storage fails for a moment: the hand-over stays for later.
|
||||
const failing = vi.fn().mockRejectedValue(new Error("The database connection is closing."));
|
||||
const second = { userId: "user-1", sessionId: "session-2", inspectionId: "srv-2" };
|
||||
await expect(store.handOverSessionPhotos(second, failing)).rejects.toThrow();
|
||||
expect(readHandOvers()).toEqual([second]);
|
||||
});
|
||||
|
||||
it("finishes only the user's own open hand-overs", async () => {
|
||||
vi.stubGlobal("window", { localStorage: memoryStorage() });
|
||||
await takePhoto("session-1");
|
||||
await takePhoto("session-2", "user-2");
|
||||
const own = { userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" };
|
||||
const foreign = { userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" };
|
||||
rememberHandOver(own);
|
||||
rememberHandOver(foreign);
|
||||
|
||||
expect(await store.finishHandOvers("user-1")).toBe(1);
|
||||
|
||||
const [ownPhoto] = await store.listSessionPhotos("session-1");
|
||||
expect(ownPhoto).toMatchObject({ state: "ready", inspectionId: "srv-1" });
|
||||
const [foreignPhoto] = await store.listSessionPhotos("session-2");
|
||||
expect(foreignPhoto.state).toBe("draft");
|
||||
expect(readHandOvers()).toEqual([foreign]);
|
||||
});
|
||||
|
||||
it("keeps the photos of a hand-over that is not finished yet, however old they are", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
|
||||
await takePhoto("handed-over");
|
||||
await takePhoto("abandoned");
|
||||
const handOvers = () => [{ userId: "user-1", sessionId: "handed-over", inspectionId: "srv-1" }];
|
||||
|
||||
const purged = await store.purgeStaleDraftPhotos(
|
||||
Date.parse("2026-10-05T08:00:00.000Z"),
|
||||
() => [],
|
||||
handOvers,
|
||||
);
|
||||
|
||||
expect(purged).toBe(1);
|
||||
expect(await store.listSessionPhotos("handed-over")).toHaveLength(1);
|
||||
expect(await store.listSessionPhotos("abandoned")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes only unsaved photos; the queue sheet discards only photos waiting on their own", async () => {
|
||||
const draft = await takePhoto("session-1");
|
||||
const ready = await takePhoto("session-2");
|
||||
await store.attachSessionToInspection("session-2", "srv-2");
|
||||
const waiting = await takePhoto("session-3");
|
||||
await store.attachSessionToQueuedInspection("session-3", "key-3");
|
||||
|
||||
await store.removePhoto(ready.id);
|
||||
await store.removePhoto(waiting.id);
|
||||
await store.removePhoto(draft.id);
|
||||
|
||||
expect(await store.listSessionPhotos("session-1")).toEqual([]);
|
||||
expect(await store.listSessionPhotos("session-2")).toHaveLength(1);
|
||||
expect(await store.listSessionPhotos("session-3")).toHaveLength(1);
|
||||
|
||||
await store.discardReadyPhoto(waiting.id);
|
||||
await store.discardReadyPhoto(ready.id);
|
||||
|
||||
expect(await store.listSessionPhotos("session-3")).toHaveLength(1);
|
||||
expect(await store.listSessionPhotos("session-2")).toEqual([]);
|
||||
});
|
||||
|
||||
it("discarding a form draft removes its unsaved photos only", async () => {
|
||||
@@ -289,7 +438,10 @@ describe("photo store", () => {
|
||||
vi.setSystemTime(new Date("2026-10-03T08:00:00.000Z"));
|
||||
await takePhoto("recent-draft");
|
||||
|
||||
const purged = await store.purgeStaleDraftPhotos(Date.parse("2026-10-04T08:00:01.000Z"));
|
||||
const purged = await store.purgeStaleDraftPhotos(
|
||||
Date.parse("2026-10-04T08:00:01.000Z"),
|
||||
() => [],
|
||||
);
|
||||
|
||||
expect(purged).toBe(1);
|
||||
expect(await store.listSessionPhotos("old-draft")).toEqual([]);
|
||||
@@ -297,6 +449,23 @@ describe("photo store", () => {
|
||||
expect(await store.listSessionPhotos("recent-draft")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the photos of a form draft that can still be restored, however old they are", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
|
||||
await takePhoto("restorable");
|
||||
await takePhoto("abandoned");
|
||||
const now = Date.parse("2026-10-05T08:00:00.000Z");
|
||||
// The draft was saved again yesterday: it is offered for 3 more days.
|
||||
const readDrafts = vi.fn(() => [{ photoSessionId: "restorable", colonyStrength: "8" }, null]);
|
||||
|
||||
const purged = await store.purgeStaleDraftPhotos(now, readDrafts);
|
||||
|
||||
expect(readDrafts).toHaveBeenCalledWith(now);
|
||||
expect(purged).toBe(1);
|
||||
expect(await store.listSessionPhotos("restorable")).toHaveLength(1);
|
||||
expect(await store.listSessionPhotos("abandoned")).toEqual([]);
|
||||
});
|
||||
|
||||
it("never stores a photo that still carries EXIF", async () => {
|
||||
const { PhotoMetadataError } = await import("./reencode");
|
||||
const exifHeader = Array.from("Exif\0\0", (char) => char.charCodeAt(0));
|
||||
@@ -324,4 +493,42 @@ describe("photo store", () => {
|
||||
const names = (await store.listSessionPhotos("session-1")).map((photo) => photo.fileName);
|
||||
expect(names).toEqual(["durchsicht-2026-10-13-2.jpg", "durchsicht-2026-10-13-3.jpg"]);
|
||||
});
|
||||
|
||||
it("gives up a hand-over after its lifetime: its photos are purged and the note goes", async () => {
|
||||
vi.stubGlobal("window", { localStorage: memoryStorage() });
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
|
||||
await takePhoto("expired");
|
||||
await takePhoto("open");
|
||||
const start = Date.parse("2026-10-01T08:00:00.000Z");
|
||||
rememberHandOver({ userId: "user-1", sessionId: "expired", inspectionId: "srv-1" }, start);
|
||||
rememberHandOver(
|
||||
{ userId: "user-1", sessionId: "open", inspectionId: "srv-2" },
|
||||
start + 3 * 24 * 3600 * 1000,
|
||||
);
|
||||
|
||||
const purged = await store.purgeStaleDraftPhotos(start + 3 * 24 * 3600 * 1000 + 60_000, () => []);
|
||||
|
||||
expect(purged).toBe(1);
|
||||
expect(await store.listSessionPhotos("expired")).toEqual([]);
|
||||
expect(await store.listSessionPhotos("open")).toHaveLength(1);
|
||||
expect(readHandOvers().map((handOver) => handOver.sessionId)).toEqual(["open"]);
|
||||
});
|
||||
|
||||
it("deletes the unsaved photos of everyone but the given user, or of everyone", async () => {
|
||||
await takePhoto("mine", "user-1");
|
||||
await takePhoto("theirs", "user-2");
|
||||
await takePhoto("queued", "user-2");
|
||||
await store.attachSessionToQueuedInspection("queued", "key-q");
|
||||
|
||||
await store.discardDraftPhotosExcept("user-1");
|
||||
|
||||
expect(await store.listSessionPhotos("mine")).toHaveLength(1);
|
||||
expect(await store.listSessionPhotos("theirs")).toEqual([]);
|
||||
// Queued photos belong to their queue entry, not to a form.
|
||||
expect(await store.listSessionPhotos("queued")).toHaveLength(1);
|
||||
|
||||
await store.discardDraftPhotosExcept(null);
|
||||
expect(await store.listSessionPhotos("mine")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,24 @@
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { uploadDocument } from "@/lib/documents/api";
|
||||
import { getDb, toQueueError, type QueueError, type StoredPhoto } from "@/lib/offline/db";
|
||||
import { DRAFT_MAX_AGE_MS, readLiveFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import type { CreateInspectionInput } from "@/lib/inspections/types";
|
||||
import {
|
||||
getDb,
|
||||
newPendingEntry,
|
||||
toQueueError,
|
||||
type QueueError,
|
||||
type StoredPhoto,
|
||||
} from "@/lib/offline/db";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type { SyncResult } from "@/lib/offline/sync";
|
||||
import {
|
||||
forgetHandOver,
|
||||
isHandOverExpired,
|
||||
readHandOverRecords,
|
||||
rememberHandOver,
|
||||
type HandOverRecord,
|
||||
type PhotoHandOver,
|
||||
} from "./handover";
|
||||
import { PhotoMetadataError, containsExifOrGps } from "./reencode";
|
||||
|
||||
/*
|
||||
@@ -23,9 +39,9 @@ import { PhotoMetadataError, containsExifOrGps } from "./reencode";
|
||||
|
||||
export const PHOTOS_PER_INSPECTION_MAX = 5;
|
||||
|
||||
// As long as an unsaved form draft lives (use-form-draft.ts): once the draft
|
||||
// is gone, nobody can reach its photos any more.
|
||||
const DRAFT_PHOTO_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
// As long as an unsaved form draft lives (use-form-draft.ts): a photo taken
|
||||
// in a form that never saved its draft is kept that long too.
|
||||
const DRAFT_PHOTO_MAX_AGE_MS = DRAFT_MAX_AGE_MS;
|
||||
|
||||
// Rejections that may pass on the next try: session refresh, timeout, the
|
||||
// same key still in progress, rate limit, server trouble.
|
||||
@@ -97,24 +113,96 @@ export async function listSessionPhotos(sessionId: string): Promise<StoredPhoto[
|
||||
return photos.sort(byCaptureOrder);
|
||||
}
|
||||
|
||||
export async function removePhoto(id: string): Promise<void> {
|
||||
/** Deletes one photo, but only while it is in state `state`. */
|
||||
async function deletePhotoIn(id: string, state: PhotoState): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("pending-photos", id);
|
||||
const tx = db.transaction("pending-photos", "readwrite");
|
||||
const photo = await tx.store.get(id);
|
||||
if (photo?.state === state) {
|
||||
await tx.store.delete(id);
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/** Rewrites the session's photos that are in state `from`; others stay as they are. */
|
||||
/**
|
||||
* „Entfernen" in the form: deletes a photo not saved yet. One that already
|
||||
* belongs to a saved or queued inspection is never touched from a form.
|
||||
*/
|
||||
export function removePhoto(id: string): Promise<void> {
|
||||
return deletePhotoIn(id, "draft");
|
||||
}
|
||||
|
||||
/** „Verwerfen" in the queue sheet: a photo waiting on its own (state ready). */
|
||||
export function discardReadyPhoto(id: string): Promise<void> {
|
||||
return deletePhotoIn(id, "ready");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the session's photos that are in state `from`; others stay as they
|
||||
* are. Returns how many it rewrote.
|
||||
*/
|
||||
async function moveSessionPhotos(
|
||||
sessionId: string,
|
||||
from: PhotoState,
|
||||
change: (photo: StoredPhoto) => StoredPhoto,
|
||||
): Promise<void> {
|
||||
): Promise<number> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-photos", "readwrite");
|
||||
const photos = await tx.store.index("sessionId").getAll(sessionId);
|
||||
await Promise.all(
|
||||
photos.filter((photo) => photo.state === from).map((photo) => tx.store.put(change(photo))),
|
||||
);
|
||||
const moving = photos.filter((photo) => photo.state === from);
|
||||
await Promise.all(moving.map((photo) => tx.store.put(change(photo))));
|
||||
await tx.done;
|
||||
return moving.length;
|
||||
}
|
||||
|
||||
function waitingFor(photo: StoredPhoto, inspectionIdempotencyKey: string): StoredPhoto {
|
||||
return { ...photo, state: "waiting-for-inspection", inspectionIdempotencyKey };
|
||||
}
|
||||
|
||||
export interface MovedPhotos {
|
||||
moved: number;
|
||||
/** Did not fit (PHOTOS_PER_INSPECTION_MAX) and were deleted. */
|
||||
discarded: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Weitermachen" with an earlier draft: the photos already taken in the
|
||||
* fresh form join the restored draft's session, in the order they were
|
||||
* taken, as long as it holds fewer than PHOTOS_PER_INSPECTION_MAX. The rest
|
||||
* are deleted - the form can no longer reach them, so it says so instead of
|
||||
* leaving them behind unseen.
|
||||
*/
|
||||
export async function moveDraftPhotos(
|
||||
fromSessionId: string,
|
||||
toSessionId: string,
|
||||
): Promise<MovedPhotos> {
|
||||
if (fromSessionId === toSessionId) {
|
||||
return { moved: 0, discarded: 0 };
|
||||
}
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-photos", "readwrite");
|
||||
const sessions = tx.store.index("sessionId");
|
||||
const target = await sessions.getAll(toSessionId);
|
||||
const drafts = (await sessions.getAll(fromSessionId))
|
||||
.filter((photo) => photo.state === "draft")
|
||||
.sort(byCaptureOrder);
|
||||
const room = Math.max(0, PHOTOS_PER_INSPECTION_MAX - target.length);
|
||||
const moving = drafts.slice(0, room);
|
||||
const overflow = drafts.slice(room);
|
||||
const lastNumber = Math.max(0, ...target.map(fileNumber));
|
||||
await Promise.all([
|
||||
...moving.map((photo, index) =>
|
||||
tx.store.put({
|
||||
...photo,
|
||||
sessionId: toSessionId,
|
||||
// Numbered on from the session it joins: names stay unique there.
|
||||
fileName: photo.fileName.replace(FILE_NUMBER, `-${lastNumber + index + 1}.jpg`),
|
||||
}),
|
||||
),
|
||||
...overflow.map((photo) => tx.store.delete(photo.id)),
|
||||
]);
|
||||
await tx.done;
|
||||
return { moved: moving.length, discarded: overflow.length };
|
||||
}
|
||||
|
||||
/** "Entwurf verwerfen": deletes the session's unsaved photos, never saved ones. */
|
||||
@@ -128,8 +216,14 @@ export async function discardSessionPhotos(sessionId: string): Promise<void> {
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/** The inspection was saved online: its photos are ready to upload. */
|
||||
export function attachSessionToInspection(sessionId: string, inspectionId: string): Promise<void> {
|
||||
/**
|
||||
* The inspection was saved online: its photos are ready to upload. Returns
|
||||
* how many photos it handed over - 0 means there is nothing to send.
|
||||
*/
|
||||
export function attachSessionToInspection(
|
||||
sessionId: string,
|
||||
inspectionId: string,
|
||||
): Promise<number> {
|
||||
return moveSessionPhotos(sessionId, "draft", (photo) => ({
|
||||
...photo,
|
||||
state: "ready",
|
||||
@@ -137,28 +231,92 @@ export function attachSessionToInspection(sessionId: string, inspectionId: strin
|
||||
}));
|
||||
}
|
||||
|
||||
/** The inspection went into the offline queue: its photos wait for it. */
|
||||
export function attachSessionToQueuedInspection(
|
||||
sessionId: string,
|
||||
inspectionIdempotencyKey: string,
|
||||
): Promise<void> {
|
||||
return moveSessionPhotos(sessionId, "draft", (photo) => ({
|
||||
...photo,
|
||||
state: "waiting-for-inspection",
|
||||
inspectionIdempotencyKey,
|
||||
}));
|
||||
/**
|
||||
* Online save: ties the form's photos to the saved inspection. A hand-over
|
||||
* (lib/photos/handover.ts) is noted first and removed once they belong to
|
||||
* it: if storage fails now, the next start or sync of this user finishes
|
||||
* it, and until then the photos are not purged. Returns how many photos it
|
||||
* handed over; throws, with the hand-over still noted, if that failed.
|
||||
*/
|
||||
export async function handOverSessionPhotos(
|
||||
handOver: PhotoHandOver,
|
||||
attach: typeof attachSessionToInspection = attachSessionToInspection,
|
||||
): Promise<number> {
|
||||
rememberHandOver(handOver);
|
||||
const handedOver = await attach(handOver.sessionId, handOver.inspectionId);
|
||||
forgetHandOver(handOver.sessionId);
|
||||
return handedOver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes attachSessionToQueuedInspection when the inspection could not be
|
||||
* queued after all: the photos belong to the still open form again.
|
||||
* Finishes the user's hand-overs that failed before - never another user's.
|
||||
* One that fails again stays noted for the next try. Returns how many photos
|
||||
* it handed over.
|
||||
*/
|
||||
export function releaseSessionPhotos(sessionId: string): Promise<void> {
|
||||
return moveSessionPhotos(sessionId, "waiting-for-inspection", (photo) => ({
|
||||
...photo,
|
||||
state: "draft",
|
||||
inspectionIdempotencyKey: undefined,
|
||||
}));
|
||||
export async function finishHandOvers(userId: string): Promise<number> {
|
||||
let handedOver = 0;
|
||||
for (const handOver of readHandOverRecords()) {
|
||||
if (handOver.userId !== userId) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
handedOver += await attachSessionToInspection(handOver.sessionId, handOver.inspectionId);
|
||||
forgetHandOver(handOver.sessionId);
|
||||
} catch {
|
||||
// Still noted: the next start or sync tries again.
|
||||
}
|
||||
}
|
||||
return handedOver;
|
||||
}
|
||||
|
||||
/** The inspection is in the offline queue: its photos wait for it. */
|
||||
export function attachSessionToQueuedInspection(
|
||||
sessionId: string,
|
||||
inspectionIdempotencyKey: string,
|
||||
): Promise<number> {
|
||||
return moveSessionPhotos(sessionId, "draft", (photo) =>
|
||||
waitingFor(photo, inspectionIdempotencyKey),
|
||||
);
|
||||
}
|
||||
|
||||
function abortQuietly(tx: { abort: () => void }): void {
|
||||
try {
|
||||
tx.abort();
|
||||
} catch {
|
||||
// Already finished or aborted by the failed request itself.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline save: queues the inspection and ties the form's unsaved photos to
|
||||
* its entry in one transaction. Either both happen or neither: if queueing
|
||||
* fails, the photos still belong to the open form; a photo never waits for
|
||||
* an entry that does not exist. Returns how many photos went along.
|
||||
*/
|
||||
export async function queueInspectionWithPhotos(
|
||||
sessionId: string,
|
||||
input: CreateInspectionInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<number> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction(["pending-inspections", "pending-photos"], "readwrite");
|
||||
try {
|
||||
const photos = tx.objectStore("pending-photos");
|
||||
const drafts = (await photos.index("sessionId").getAll(sessionId)).filter(
|
||||
(photo) => photo.state === "draft",
|
||||
);
|
||||
await Promise.all(drafts.map((photo) => photos.put(waitingFor(photo, idempotencyKey))));
|
||||
await tx
|
||||
.objectStore("pending-inspections")
|
||||
.add(newPendingEntry(input, idempotencyKey, ownerUserId) as never);
|
||||
await tx.done;
|
||||
return drafts.length;
|
||||
} catch (error) {
|
||||
abortQuietly(tx);
|
||||
await tx.done.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,19 +351,64 @@ export async function discardPhotosForInspection(inspectionIdempotencyKey: strin
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/** Deletes photos of form drafts older than 3 days; returns how many. */
|
||||
export async function purgeStaleDraftPhotos(now: number): Promise<number> {
|
||||
/** The photo sessions that drafts on this device still refer to. */
|
||||
function sessionsOfDrafts(drafts: readonly unknown[]): Set<string> {
|
||||
const sessions = drafts.flatMap((values) => {
|
||||
const sessionId = (values as { photoSessionId?: unknown } | null)?.photoSessionId;
|
||||
return typeof sessionId === "string" ? [sessionId] : [];
|
||||
});
|
||||
return new Set(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes unsaved photos older than 3 days that no form draft offers any
|
||||
* more; returns how many. A draft that is still offered keeps all its
|
||||
* photos, however old: „Weitermachen" must bring them back. So does a
|
||||
* saved inspection whose hand-over is not finished yet.
|
||||
*/
|
||||
export async function purgeStaleDraftPhotos(
|
||||
now: number,
|
||||
readDrafts: (now: number) => unknown[] = readLiveFormDrafts,
|
||||
readOpenHandOvers: () => (PhotoHandOver & Partial<HandOverRecord>)[] = readHandOverRecords,
|
||||
): Promise<number> {
|
||||
// Read before the transaction opens: it must not wait for anything else.
|
||||
const offered = sessionsOfDrafts(readDrafts(now));
|
||||
for (const handOver of readOpenHandOvers()) {
|
||||
if (isHandOverExpired(handOver, now)) {
|
||||
// Given up: its photos are purged like any draft, the note goes too.
|
||||
forgetHandOver(handOver.sessionId);
|
||||
} else {
|
||||
offered.add(handOver.sessionId);
|
||||
}
|
||||
}
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-photos", "readwrite");
|
||||
const stale = (await tx.store.getAll()).filter(
|
||||
(photo) =>
|
||||
photo.state === "draft" && now - Date.parse(photo.createdAt) > DRAFT_PHOTO_MAX_AGE_MS,
|
||||
photo.state === "draft" &&
|
||||
!offered.has(photo.sessionId) &&
|
||||
now - Date.parse(photo.createdAt) > DRAFT_PHOTO_MAX_AGE_MS,
|
||||
);
|
||||
await Promise.all(stale.map((photo) => tx.store.delete(photo.id)));
|
||||
await tx.done;
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-out and user switch: deletes the unsaved photos (state draft) of every
|
||||
* user but `userId`; `null` deletes all of them. Photos that belong to a
|
||||
* queued or saved inspection stay with their queue entry.
|
||||
*/
|
||||
export async function discardDraftPhotosExcept(userId: string | null): Promise<void> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-photos", "readwrite");
|
||||
const drafts = (await tx.store.getAll()).filter(
|
||||
(photo) => photo.state === "draft" && photo.ownerUserId !== userId,
|
||||
);
|
||||
await Promise.all(drafts.map((photo) => tx.store.delete(photo.id)));
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's photos waiting to be uploaded on their own (their inspection is
|
||||
* on the server), rejected ones included - the queue sheet lists them.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import type {
|
||||
FailedDomainEventView,
|
||||
FailedWebhookDeliveryView,
|
||||
JobName,
|
||||
JobRunView,
|
||||
SystemOverview,
|
||||
} from "./types";
|
||||
|
||||
export function fetchSystemOverview(): Promise<SystemOverview> {
|
||||
return apiFetch<SystemOverview>("/system/overview");
|
||||
}
|
||||
|
||||
export function fetchJobRuns(job?: JobName): Promise<JobRunView[]> {
|
||||
const query = job ? `?job=${encodeURIComponent(job)}` : "";
|
||||
return apiFetch<JobRunView[]>(`/system/job-runs${query}`);
|
||||
}
|
||||
|
||||
export function fetchFailedDomainEvents(): Promise<FailedDomainEventView[]> {
|
||||
return apiFetch<FailedDomainEventView[]>("/system/domain-events/failed");
|
||||
}
|
||||
|
||||
export function retryDomainEvent(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/system/domain-events/${encodeURIComponent(id)}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchFailedWebhookDeliveries(): Promise<FailedWebhookDeliveryView[]> {
|
||||
return apiFetch<FailedWebhookDeliveryView[]>("/system/webhook-deliveries/failed");
|
||||
}
|
||||
|
||||
export function retryWebhookDelivery(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/system/webhook-deliveries/${encodeURIComponent(id)}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DOMAIN_EVENT_LABELS,
|
||||
JOB_LABELS,
|
||||
JOB_RUN_STATUS_LABELS,
|
||||
QUEUE_STALE_WARNING_SECONDS,
|
||||
WEBHOOK_DELIVERY_STATUS_LABELS,
|
||||
attemptsLabel,
|
||||
formatAge,
|
||||
} from "./labels";
|
||||
import { JOB_NAMES } from "./types";
|
||||
|
||||
describe("system labels", () => {
|
||||
it("labels every job", () => {
|
||||
expect(JOB_NAMES.every((job) => JOB_LABELS[job].length > 0)).toBe(true);
|
||||
expect(JOB_LABELS.cleanup).toBe("Aufräumen");
|
||||
});
|
||||
|
||||
it("labels the run and delivery states", () => {
|
||||
expect(JOB_RUN_STATUS_LABELS.FAILED).toBe("Fehlgeschlagen");
|
||||
expect(WEBHOOK_DELIVERY_STATUS_LABELS.DELIVERED).toBe("Zugestellt");
|
||||
expect(WEBHOOK_DELIVERY_STATUS_LABELS.PENDING).toBe("Offen");
|
||||
expect(DOMAIN_EVENT_LABELS.InspectionRecorded).toBe("Durchsicht erfasst");
|
||||
});
|
||||
|
||||
it("warns after ten minutes", () => {
|
||||
expect(QUEUE_STALE_WARNING_SECONDS).toBe(600);
|
||||
});
|
||||
|
||||
it("pluralises attempts", () => {
|
||||
expect(attemptsLabel(1)).toBe("1 Versuch");
|
||||
expect(attemptsLabel(0)).toBe("0 Versuche");
|
||||
expect(attemptsLabel(8)).toBe("8 Versuche");
|
||||
});
|
||||
|
||||
it("formats ages in German", () => {
|
||||
expect(formatAge(5)).toBe("vor wenigen Sekunden");
|
||||
expect(formatAge(180)).toBe("vor 3 Min.");
|
||||
expect(formatAge(7300)).toBe("vor 2 Std.");
|
||||
expect(formatAge(2 * 86400 + 10)).toBe("vor 2 Tagen");
|
||||
expect(formatAge(86400)).toBe("vor 1 Tag");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { WebhookDeliveryStatus } from "@/lib/webhooks/types";
|
||||
import type { JobName, JobRunStatus } from "./types";
|
||||
|
||||
/** Same wording as JOB_LABELS_DE in the backend. */
|
||||
export const JOB_LABELS: Record<JobName, string> = {
|
||||
"daily-evaluation": "Tägliche Auswertung",
|
||||
"task-templates": "Aufgaben aus Vorlagen",
|
||||
cleanup: "Aufräumen",
|
||||
"app-backup": "App-Backup",
|
||||
rekey: "Schlüssel umstellen",
|
||||
};
|
||||
|
||||
export const JOB_RUN_STATUS_LABELS: Record<JobRunStatus, string> = {
|
||||
RUNNING: "Läuft",
|
||||
SUCCEEDED: "Erfolgreich",
|
||||
FAILED: "Fehlgeschlagen",
|
||||
} as const;
|
||||
|
||||
export const WEBHOOK_DELIVERY_STATUS_LABELS: Record<WebhookDeliveryStatus, string> = {
|
||||
PENDING: "Offen",
|
||||
PROCESSING: "Wird zugestellt",
|
||||
DELIVERED: "Zugestellt",
|
||||
FAILED: "Fehlgeschlagen",
|
||||
};
|
||||
|
||||
export const DOMAIN_EVENT_LABELS: Record<string, string> = {
|
||||
InspectionRecorded: "Durchsicht erfasst",
|
||||
InspectionCorrected: "Durchsicht korrigiert",
|
||||
VarroaMeasured: "Varroamessung erfasst",
|
||||
VarroaCorrected: "Varroamessung korrigiert",
|
||||
TreatmentRecorded: "Behandlung erfasst",
|
||||
TreatmentCorrected: "Behandlung korrigiert",
|
||||
FeedingRecorded: "Fütterung erfasst",
|
||||
FeedingCorrected: "Fütterung korrigiert",
|
||||
HarvestRecorded: "Ernte erfasst",
|
||||
HarvestCorrected: "Ernte korrigiert",
|
||||
ColonyStatusChanged: "Volksstatus geändert",
|
||||
ColonyEnded: "Volk beendet",
|
||||
BatchBottled: "Charge abgefüllt",
|
||||
BottlingCorrected: "Abfüllung korrigiert",
|
||||
StockChanged: "Bestand geändert",
|
||||
TaskCreated: "Aufgabe angelegt",
|
||||
TaskUpdated: "Aufgabe geändert",
|
||||
TaskCompleted: "Aufgabe erledigt",
|
||||
WithdrawalEnded: "Wartezeit beendet",
|
||||
TaskDue: "Aufgabe fällig",
|
||||
QueenAged: "Königin gealtert",
|
||||
VarroaMeasurementDue: "Varroamessung fällig",
|
||||
InspectionOverdue: "Durchsicht überfällig",
|
||||
};
|
||||
|
||||
/** The oldest open event is this old (seconds) before the worker counts as stuck. */
|
||||
export const QUEUE_STALE_WARNING_SECONDS = 600;
|
||||
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
|
||||
/** "vor 3 Min." / "vor 2 Std." / "vor 2 Tagen". */
|
||||
export function formatAge(seconds: number): string {
|
||||
if (seconds < SECONDS_PER_MINUTE) {
|
||||
return "vor wenigen Sekunden";
|
||||
}
|
||||
if (seconds < SECONDS_PER_HOUR) {
|
||||
return `vor ${Math.floor(seconds / SECONDS_PER_MINUTE)} Min.`;
|
||||
}
|
||||
if (seconds < SECONDS_PER_DAY) {
|
||||
return `vor ${Math.floor(seconds / SECONDS_PER_HOUR)} Std.`;
|
||||
}
|
||||
const days = Math.floor(seconds / SECONDS_PER_DAY);
|
||||
return days === 1 ? "vor 1 Tag" : `vor ${days} Tagen`;
|
||||
}
|
||||
|
||||
/** "1 Versuch" / "8 Versuche". */
|
||||
export function attemptsLabel(attempts: number): string {
|
||||
return attempts === 1 ? "1 Versuch" : `${attempts} Versuche`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const JOB_NAMES = [
|
||||
"daily-evaluation",
|
||||
"task-templates",
|
||||
"cleanup",
|
||||
"app-backup",
|
||||
"rekey",
|
||||
] as const;
|
||||
|
||||
export type JobName = (typeof JOB_NAMES)[number];
|
||||
|
||||
export type JobRunStatus = "RUNNING" | "SUCCEEDED" | "FAILED";
|
||||
|
||||
export interface JobRunView {
|
||||
id: string;
|
||||
job: JobName;
|
||||
status: JobRunStatus;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
error: string | null;
|
||||
result: Record<string, number | string | boolean | null> | null;
|
||||
}
|
||||
|
||||
export interface SystemOverview {
|
||||
jobs: { job: JobName; lastRun: JobRunView | null }[];
|
||||
domainEvents: {
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
oldestPendingAgeSeconds: number | null;
|
||||
};
|
||||
webhookDeliveries: { pending: number; failed: number };
|
||||
}
|
||||
|
||||
export interface FailedDomainEventView {
|
||||
id: string;
|
||||
type: string;
|
||||
aggregateType: string;
|
||||
aggregateId: string;
|
||||
occurredAt: string;
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
failedHandlers: { handler: string; lastError: string | null }[];
|
||||
}
|
||||
|
||||
export interface FailedWebhookDeliveryView {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
targetHost: string;
|
||||
event: string;
|
||||
attempts: number;
|
||||
lastStatusCode: number | null;
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
CreateWebhookInput,
|
||||
UpdateWebhookInput,
|
||||
WebhookDeliveryView,
|
||||
WebhookSubscription,
|
||||
WebhookSubscriptionWithSecret,
|
||||
} from "./types";
|
||||
@@ -30,3 +31,9 @@ export function updateWebhook(
|
||||
export function deleteWebhook(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/webhooks/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function fetchWebhookDeliveries(subscriptionId: string): Promise<WebhookDeliveryView[]> {
|
||||
return apiFetch<WebhookDeliveryView[]>(
|
||||
`/webhooks/${encodeURIComponent(subscriptionId)}/deliveries`,
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import { WebhookEvent } from "./types";
|
||||
export const WEBHOOK_EVENT_LABELS: Record<WebhookEvent, string> = {
|
||||
"colony.created": "Volk angelegt",
|
||||
"inspection.created": "Durchsicht erfasst",
|
||||
"varroa.measured": "Varroamessung erfasst",
|
||||
"treatment.created": "Behandlung erfasst",
|
||||
"feeding.created": "Fütterung erfasst",
|
||||
"queen.created": "Königin angelegt",
|
||||
"queen.replaced": "Königin zugewiesen/ersetzt",
|
||||
"harvest.created": "Ernte erfasst",
|
||||
"batch.created": "Honigcharge angelegt",
|
||||
"batch.updated": "Honigcharge geändert",
|
||||
"product.stock_changed": "Lagerbestand geändert",
|
||||
"stock.changed": "Bestand einer Charge geändert",
|
||||
"task.created": "Aufgabe angelegt",
|
||||
"task.completed": "Aufgabe erledigt",
|
||||
};
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
export const WEBHOOK_EVENTS = [
|
||||
"colony.created",
|
||||
"inspection.created",
|
||||
"varroa.measured",
|
||||
"treatment.created",
|
||||
"feeding.created",
|
||||
"queen.created",
|
||||
"queen.replaced",
|
||||
"harvest.created",
|
||||
"batch.created",
|
||||
"batch.updated",
|
||||
"product.stock_changed",
|
||||
"stock.changed",
|
||||
"task.created",
|
||||
"task.completed",
|
||||
] as const;
|
||||
@@ -37,3 +41,16 @@ export interface UpdateWebhookInput {
|
||||
events?: WebhookEvent[];
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export type WebhookDeliveryStatus = "PENDING" | "PROCESSING" | "DELIVERED" | "FAILED";
|
||||
|
||||
export interface WebhookDeliveryView {
|
||||
id: string;
|
||||
event: string;
|
||||
status: WebhookDeliveryStatus;
|
||||
attempts: number;
|
||||
lastStatusCode: number | null;
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
Reference in new issue
Block a user