- Withdrawal wording from one backend helper everywhere ("bis
einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
crosses New Year); withdrawal-status returns lastBlockedDay and the
ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
colony codes; collapsible automation rule cards; varroa list newest
first with chart axes; text fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
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,
|
|
livingColoniesFromPacks,
|
|
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([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("livingColoniesFromPacks order", () => {
|
|
it("sorts colony codes naturally (V-2 before V-10)", () => {
|
|
const pack = storedPack(
|
|
offlinePack({
|
|
colonies: [packColony("c1", "V-10"), packColony("c2", "V-2"), packColony("c3", "V-010")],
|
|
}),
|
|
);
|
|
expect(livingColoniesFromPacks([pack]).map((colony) => colony.code)).toEqual(["V-2", "V-010", "V-10"]);
|
|
});
|
|
});
|