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,263 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useRef } from "react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { discardQueueItem, listQueue, summarizeQueue, type QueueItem } from "@/lib/offline/db";
|
||||
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OfflineBand } from "./offline-band";
|
||||
|
||||
vi.mock("@/lib/offline/db", () => ({
|
||||
summarizeQueue: vi.fn(),
|
||||
listQueue: vi.fn(),
|
||||
discardQueueItem: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn(), resumeSync: vi.fn() }));
|
||||
|
||||
const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 };
|
||||
const REJECTED: QueueItem = {
|
||||
store: "pending-inspections",
|
||||
id: 7,
|
||||
label: "Durchsicht",
|
||||
queuedAt: "2026-10-13T08:15:00.000Z",
|
||||
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
|
||||
};
|
||||
const WAITING: QueueItem = {
|
||||
store: "pending-feedings",
|
||||
id: 3,
|
||||
label: "Fütterung",
|
||||
queuedAt: "2026-10-13T08:20:00.000Z",
|
||||
};
|
||||
|
||||
function setOnLine(value: boolean) {
|
||||
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value);
|
||||
}
|
||||
|
||||
/** The band as the layout places it: above the page content wrapper. */
|
||||
function BandOnPage() {
|
||||
const pageContentRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
|
||||
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
|
||||
Seiteninhalt
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBand() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<BandOnPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the sheet from the rejected entries and discards the rejected one. */
|
||||
async function discardRejectedEntry(nextSummary: { own: number; failed: number; foreign: number }) {
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }),
|
||||
);
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
vi.mocked(summarizeQueue).mockResolvedValue(nextSummary);
|
||||
vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []);
|
||||
await userEvent.click(
|
||||
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
|
||||
);
|
||||
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
|
||||
return sheet;
|
||||
}
|
||||
|
||||
describe("OfflineBand", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(summarizeQueue).mockReset();
|
||||
vi.mocked(listQueue).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
|
||||
vi.mocked(resumeSync).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("announces „Offline – 2 Einträge warten\" in a status region", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Offline – 2 Einträge warten"),
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
|
||||
expect(summarizeQueue).toHaveBeenCalledWith("user-1");
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says „1 Eintrag wartet\" for a single entry", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Offline – 1 Eintrag wartet"),
|
||||
);
|
||||
});
|
||||
|
||||
it("tells that input is kept while offline with an empty queue", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(
|
||||
"Offline – Eingaben werden gespeichert und später gesendet",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an empty status region online with an empty queue", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 3 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() => expect(summarizeQueue).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
// The live region must exist before its first message, or that message is not announced.
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
expect(status).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("„Jetzt senden\" starts syncPendingEntries", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
renderBand();
|
||||
const button = await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
|
||||
// Online on mount the band already tries once by itself.
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(1));
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(2));
|
||||
expect(syncPendingEntries).toHaveBeenLastCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("its button is at least 44 px", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "1 Eintrag wartet – Jetzt senden" }),
|
||||
).toHaveClass("min-h-11");
|
||||
expect(screen.getByRole("button", { name: "1 Eintrag abgelehnt – ansehen" })).toHaveClass(
|
||||
"min-h-11",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the queue sheet from the rejected entries", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 2, foreign: 0 });
|
||||
renderBand();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "2 Einträge abgelehnt – ansehen" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
|
||||
expect(listQueue).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
|
||||
renderBand();
|
||||
|
||||
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
|
||||
);
|
||||
// Offline, the band still says so - focus lands on that message.
|
||||
await waitFor(() => expect(screen.getByRole("status")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("moves focus to the page content when the band has nothing left to show", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
|
||||
renderBand();
|
||||
|
||||
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("page-content")).toHaveFocus());
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("keeps the sheet open with the waiting entries after the last rejected one is discarded", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED, WAITING]);
|
||||
renderBand();
|
||||
|
||||
const sheet = await discardRejectedEntry({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: /abgelehnt – ansehen/ })).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(sheet).getByRole("button", { name: "Schließen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
|
||||
);
|
||||
// The trigger is gone; focus goes to the band, which still shows the
|
||||
// waiting entry (Base UI picks its first focusable element, the send button).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toContainElement(document.activeElement as HTMLElement),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "1 Eintrag wartet – Jetzt senden" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("opens sync for the signed-in user before its first automatic sync", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
expect(resumeSync).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(resumeSync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("sends by itself as soon as the connection is back", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderBand();
|
||||
await screen.findByText("Offline – 1 Eintrag wartet");
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
|
||||
setOnLine(true);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user