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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user