feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,229 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { openDB } from "idb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type OfflineDbModule = typeof import("./db");
|
||||
|
||||
const DB_NAME = "apicore-offline";
|
||||
const VERSION_2_STORES = [
|
||||
"pending-inspections",
|
||||
"pending-treatments",
|
||||
"pending-feedings",
|
||||
"pending-varroa-measurements",
|
||||
"pending-swarm-control-checks",
|
||||
"pending-harvests",
|
||||
];
|
||||
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z" };
|
||||
const TREATMENT = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" };
|
||||
const FEEDING = {
|
||||
colonyId: "colony-1",
|
||||
date: "2026-10-01T00:00:00.000Z",
|
||||
feedType: "FEED_SYRUP" as const,
|
||||
amount: 2,
|
||||
unit: "kg",
|
||||
};
|
||||
const REJECTION = {
|
||||
status: 400,
|
||||
message: "Volk V-03 ist beendet.",
|
||||
at: "2026-10-13T09:00:00.000Z",
|
||||
};
|
||||
|
||||
function photo(id: string, inspectionIdempotencyKey: string, ownerUserId = "user-1") {
|
||||
return {
|
||||
id,
|
||||
sessionId: "session-1",
|
||||
ownerUserId,
|
||||
blob: new Blob(["jpeg"]),
|
||||
fileName: `${id}.jpg`,
|
||||
createdAt: "2026-10-13T08:00:00.000Z",
|
||||
state: "waiting-for-inspection",
|
||||
inspectionIdempotencyKey,
|
||||
uploadIdempotencyKey: `upload-${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fresh module, so nothing is cached from the previous test's database. */
|
||||
async function loadDb(): Promise<OfflineDbModule> {
|
||||
vi.resetModules();
|
||||
return import("./db");
|
||||
}
|
||||
|
||||
/** The queue exactly as the previous release (DB version 2) left it on a phone. */
|
||||
async function createVersion2Queue(entry: object): Promise<void> {
|
||||
const db = await openDB(DB_NAME, 2, {
|
||||
upgrade(database) {
|
||||
for (const store of VERSION_2_STORES) {
|
||||
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
await db.add("pending-inspections", entry);
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("offline queue database", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
it("upgrading v2 → v3 keeps already queued inspections", async () => {
|
||||
await createVersion2Queue({
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v2",
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
});
|
||||
const db = await loadDb();
|
||||
|
||||
const entries = await db.getPendingEntries("pending-inspections");
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v2",
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
// Entries from before owners existed still count as the user's own.
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(3);
|
||||
expect([...upgraded.objectStoreNames]).toEqual(
|
||||
expect.arrayContaining([
|
||||
...VERSION_2_STORES,
|
||||
"pending-bulk",
|
||||
"pending-photos",
|
||||
"offline-packs",
|
||||
"meta",
|
||||
]),
|
||||
);
|
||||
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
|
||||
"inspectionIdempotencyKey",
|
||||
"ownerUserId",
|
||||
"sessionId",
|
||||
]);
|
||||
upgraded.close();
|
||||
});
|
||||
|
||||
it("summarizeQueue separates own, failed and foreign entries", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueTreatment(TREATMENT, "key-2", "user-1");
|
||||
await db.queueFeeding(FEEDING, "key-3", "user-2");
|
||||
const [treatment] = await db.getPendingEntries("pending-treatments");
|
||||
await db.markFailed("pending-treatments", treatment.id, REJECTION);
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 1, foreign: 1 });
|
||||
expect(await db.summarizeQueue("user-2")).toEqual({ own: 1, failed: 0, foreign: 2 });
|
||||
});
|
||||
|
||||
it("summarizeQueue counts a photo waiting on its own like an entry, never a draft or one riding with its inspection", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
|
||||
const raw = await openDB(DB_NAME);
|
||||
const ready = { ...photo("ready", "inspection-key"), state: "ready", inspectionId: "srv-1" };
|
||||
await raw.put("pending-photos", ready);
|
||||
await raw.put("pending-photos", { ...ready, id: "rejected", lastError: REJECTION });
|
||||
await raw.put("pending-photos", { ...ready, id: "foreign", ownerUserId: "user-2" });
|
||||
await raw.put("pending-photos", photo("rides-along", "inspection-key"));
|
||||
await raw.put("pending-photos", { ...photo("draft", "unused"), state: "draft" });
|
||||
raw.close();
|
||||
|
||||
// The inspection itself, plus the ready photo; the rejected one separately.
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 1, foreign: 1 });
|
||||
});
|
||||
|
||||
it("toQueueError keeps the server's status, message and body", async () => {
|
||||
const db = await loadDb();
|
||||
const { ApiError } = await import("@/lib/auth/api-client");
|
||||
const body = { statusCode: 413, message: "Datei zu groß" };
|
||||
|
||||
const error = db.toQueueError(new ApiError(413, "Datei zu groß", body));
|
||||
|
||||
expect(error).toEqual({ status: 413, message: "Datei zu groß", details: body, at: expect.any(String) });
|
||||
});
|
||||
|
||||
it("stores each entry with its key and owner", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueVarroaMeasurement(
|
||||
{ colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", method: "WASHING_METHOD" as const },
|
||||
"key-1",
|
||||
"user-1",
|
||||
);
|
||||
|
||||
const [entry] = await db.getPendingEntries("pending-varroa-measurements");
|
||||
|
||||
expect(entry).toMatchObject({ idempotencyKey: "key-1", ownerUserId: "user-1" });
|
||||
expect(Date.parse(entry.queuedAt)).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("lists only the current user's entries, oldest first, with the server's message", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueFeeding(FEEDING, "key-2", "user-2");
|
||||
await db.queueTreatment(TREATMENT, "key-3", "user-1");
|
||||
const [treatment] = await db.getPendingEntries("pending-treatments");
|
||||
await db.markFailed("pending-treatments", treatment.id, REJECTION);
|
||||
|
||||
const items = await db.listQueue("user-1");
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ store: "pending-inspections", label: "Durchsicht" }),
|
||||
expect.objectContaining({
|
||||
store: "pending-treatments",
|
||||
id: treatment.id,
|
||||
label: "Behandlung",
|
||||
lastError: REJECTION,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("discardQueueItem deletes exactly that entry", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-2", "user-1");
|
||||
const [first] = await db.listQueue("user-1");
|
||||
|
||||
await db.discardQueueItem(first);
|
||||
|
||||
const remaining = await db.getPendingEntries("pending-inspections");
|
||||
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-2"]);
|
||||
});
|
||||
|
||||
it("discarding a queued inspection discards its photos and no others", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
|
||||
const raw = await openDB(DB_NAME);
|
||||
await raw.put("pending-photos", photo("p1", "inspection-key"));
|
||||
await raw.put("pending-photos", photo("p2", "other-inspection-key"));
|
||||
const [item] = await db.listQueue("user-1");
|
||||
expect(item.photoCount).toBe(1);
|
||||
|
||||
await db.discardQueueItem(item);
|
||||
|
||||
expect(await raw.getAllKeys("pending-photos")).toEqual(["p2"]);
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
raw.close();
|
||||
});
|
||||
|
||||
it("clearQueueOf removes one owner's entries and photos and keeps everyone else's", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
await db.queueFeeding(FEEDING, "key-2", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-3", "user-2");
|
||||
const raw = await openDB(DB_NAME);
|
||||
await raw.put("pending-photos", photo("p1", "key-1", "user-1"));
|
||||
await raw.put("pending-photos", photo("p3", "key-3", "user-2"));
|
||||
|
||||
await db.clearQueueOf("user-1");
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 0, failed: 0, foreign: 1 });
|
||||
const [left] = await db.getPendingEntries("pending-inspections");
|
||||
expect(left.idempotencyKey).toBe("key-3");
|
||||
expect(await raw.getAllKeys("pending-photos")).toEqual(["p3"]);
|
||||
raw.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user