From 28f5b39e45427b1889b0bd8ad8049d66d95681bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 11 Mar 2026 13:00:16 +0100 Subject: [PATCH] add robustness implementation plan: 23 tasks across 6 chunks Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-11-robustness-plan.md | 2234 +++++++++++++++++ 1 file changed, 2234 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-11-robustness-plan.md diff --git a/docs/superpowers/plans/2026-03-11-robustness-plan.md b/docs/superpowers/plans/2026-03-11-robustness-plan.md new file mode 100644 index 0000000..2c20481 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-robustness-plan.md @@ -0,0 +1,2234 @@ +# Robustness Plan Implementation + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Harden the agw PWA against transient failures, fix silent error swallowing, move API key server-side, and fill test coverage gaps. + +**Architecture:** Cross-cutting retry utility and error boundary first, then layer-by-layer server→client hardening, finishing with test expansion. Each task is independently committable. + +**Tech Stack:** TypeScript, Vitest, React 19, Hono, Drizzle, PGlite, web-push + +**Spec:** `docs/superpowers/specs/2026-03-11-robustness-plan-design.md` + +--- + +## Chunk 1: Cross-Cutting Infrastructure + +### Task 1: Retry Utility + +**Files:** +- Create: `src/shared/retry.ts` +- Create: `src/shared/retry.test.ts` + +- [ ] **Step 1: Write the retry utility** + +```ts +export interface RetryOptions { + maxAttempts?: number + baseDelayMs?: number + signal?: AbortSignal +} + +function isTransient(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") return false + // web-push and some HTTP libraries attach statusCode directly + const statusCode = (err as { statusCode?: number }).statusCode + if (typeof statusCode === "number") { + return statusCode === 429 || (statusCode >= 500 && statusCode < 600) + } + if (err instanceof Error) { + const msg = err.message + // network-level failures + if (msg.includes("fetch failed") || msg.includes("network")) return true + // HTTP 5xx or 429 embedded in error messages + const statusMatch = msg.match(/\b(5\d{2}|429)\b/) + if (statusMatch) return true + } + return false +} + +export async function withRetry( + fn: () => Promise, + opts?: RetryOptions, +): Promise { + const maxAttempts = opts?.maxAttempts ?? 3 + const baseDelay = opts?.baseDelayMs ?? 500 + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn() + } catch (err) { + if (attempt === maxAttempts || !isTransient(err)) throw err + opts?.signal?.throwIfAborted() + const delay = baseDelay * 2 ** (attempt - 1) + await new Promise((r) => setTimeout(r, delay)) + opts?.signal?.throwIfAborted() + } + } + // unreachable, but satisfies TypeScript + throw new Error("withRetry: exhausted attempts") +} +``` + +- [ ] **Step 2: Write tests for retry utility** + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { withRetry } from "./retry" + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe("withRetry", () => { + it("returns result on first success", async () => { + const fn = vi.fn().mockResolvedValue("ok") + const result = await withRetry(fn) + expect(result).toBe("ok") + expect(fn).toHaveBeenCalledOnce() + }) + + it("retries on transient 503 error and succeeds", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("AW API 503 for url")) + .mockResolvedValue("ok") + + const promise = withRetry(fn, { baseDelayMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + const result = await promise + expect(result).toBe("ok") + expect(fn).toHaveBeenCalledTimes(2) + }) + + it("retries on 429 rate limit", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("DIP API 429 for url")) + .mockResolvedValue("ok") + + const promise = withRetry(fn, { baseDelayMs: 50 }) + await vi.advanceTimersByTimeAsync(50) + const result = await promise + expect(result).toBe("ok") + expect(fn).toHaveBeenCalledTimes(2) + }) + + it("retries on error with statusCode 503 (web-push style)", async () => { + const err = Object.assign(new Error("push failed"), { statusCode: 503 }) + const fn = vi + .fn() + .mockRejectedValueOnce(err) + .mockResolvedValue("ok") + + const promise = withRetry(fn, { baseDelayMs: 50 }) + await vi.advanceTimersByTimeAsync(50) + const result = await promise + expect(result).toBe("ok") + expect(fn).toHaveBeenCalledTimes(2) + }) + + it("does not retry on 404", async () => { + const fn = vi.fn().mockRejectedValue(new Error("AW API 404 for url")) + await expect(withRetry(fn)).rejects.toThrow("404") + expect(fn).toHaveBeenCalledOnce() + }) + + it("does not retry Zod validation errors", async () => { + const fn = vi.fn().mockRejectedValue(new Error("Expected number, received string")) + await expect(withRetry(fn)).rejects.toThrow("Expected number") + expect(fn).toHaveBeenCalledOnce() + }) + + it("throws after maxAttempts exhausted", async () => { + const fn = vi.fn().mockRejectedValue(new Error("fetch failed")) + const promise = withRetry(fn, { maxAttempts: 2, baseDelayMs: 50 }) + await vi.advanceTimersByTimeAsync(50) + await expect(promise).rejects.toThrow("fetch failed") + expect(fn).toHaveBeenCalledTimes(2) + }) + + it("uses exponential backoff", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("fetch failed")) + .mockRejectedValueOnce(new Error("fetch failed")) + .mockResolvedValue("ok") + + const promise = withRetry(fn, { maxAttempts: 3, baseDelayMs: 100 }) + // first retry: 100ms + await vi.advanceTimersByTimeAsync(100) + expect(fn).toHaveBeenCalledTimes(2) + // second retry: 200ms + await vi.advanceTimersByTimeAsync(200) + const result = await promise + expect(result).toBe("ok") + expect(fn).toHaveBeenCalledTimes(3) + }) + + it("aborts immediately when signal is aborted", async () => { + const controller = new AbortController() + const fn = vi.fn().mockRejectedValue(new Error("fetch failed")) + + const promise = withRetry(fn, { + maxAttempts: 3, + baseDelayMs: 1000, + signal: controller.signal, + }) + + // abort during first delay + await vi.advanceTimersByTimeAsync(100) + controller.abort() + await vi.advanceTimersByTimeAsync(1000) + await expect(promise).rejects.toThrow("abort") + expect(fn).toHaveBeenCalledOnce() + }) +}) +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `bun vitest run src/shared/retry.test.ts` +Expected: All 9 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/shared/retry.ts src/shared/retry.test.ts +git commit -m "add withRetry utility: exponential backoff for transient failures" +``` + +--- + +### Task 2: Error Boundary Component + +**Files:** +- Create: `src/client/shared/components/error-boundary.tsx` +- Create: `src/client/shared/components/error-boundary.test.tsx` + +- [ ] **Step 1: Write the error boundary component** + +```tsx +import { Component, type ErrorInfo, type ReactNode } from "react" + +interface Props { + children: ReactNode + fallback?: (props: { error: Error; reset: () => void }) => ReactNode +} + +interface State { + error: Error | null +} + +export class ErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack) + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + if (this.state.error) { + if (this.props.fallback) { + return this.props.fallback({ + error: this.state.error, + reset: this.reset, + }) + } + return ( +
+

Etwas ist schiefgelaufen

+

{this.state.error.message}

+ +
+ ) + } + return this.props.children + } +} +``` + +- [ ] **Step 2: Write tests for error boundary** + +```tsx +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { ErrorBoundary } from "./error-boundary" + +afterEach(cleanup) + +function ThrowingChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) throw new Error("test error") + return
child content
+} + +describe("ErrorBoundary", () => { + it("renders children when no error", () => { + render( + + + , + ) + expect(screen.getByText("child content")).toBeTruthy() + }) + + it("renders default fallback on error", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}) + render( + + + , + ) + expect(screen.getByText("Etwas ist schiefgelaufen")).toBeTruthy() + expect(screen.getByText("test error")).toBeTruthy() + spy.mockRestore() + }) + + it("renders custom fallback when provided", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}) + render( +
custom: {error.message}
} + > + +
, + ) + expect(screen.getByText("custom: test error")).toBeTruthy() + spy.mockRestore() + }) + + it("resets state when retry button clicked", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}) + let shouldThrow = true + function Child() { + if (shouldThrow) throw new Error("test error") + return
recovered
+ } + + render( + + + , + ) + expect(screen.getByText("Etwas ist schiefgelaufen")).toBeTruthy() + + shouldThrow = false + fireEvent.click(screen.getByText("Erneut versuchen")) + expect(screen.getByText("recovered")).toBeTruthy() + spy.mockRestore() + }) +}) +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `bun vitest run src/client/shared/components/error-boundary.test.tsx` +Expected: All 4 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/client/shared/components/error-boundary.tsx src/client/shared/components/error-boundary.test.tsx +git commit -m "add ErrorBoundary component with retry support" +``` + +--- + +## Chunk 2: Server-Side API Client Hardening + +### Task 3: Integrate withRetry into Server AW API Client + +**Files:** +- Modify: `src/server/shared/lib/aw-api.ts` + +- [ ] **Step 1: Add withRetry import and wrap the request helper** + +In `src/server/shared/lib/aw-api.ts`, add the import at the top: + +```ts +import { withRetry } from "../../../shared/retry" +``` + +Replace the `request` function (lines 88-115) with: + +```ts +async function request( + path: string, + params: Record, + schema: z.ZodType, +): Promise { + const url = new URL(`${AW_API_BASE}/${path}`) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + + return withRetry(async () => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), AW_API_TIMEOUT_MS) + let res: Response + try { + res = await fetch(url.toString(), { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + } catch (err) { + clearTimeout(timer) + if (err instanceof DOMException && err.name === "AbortError") { + throw new Error(`AW API request timed out for ${url}`) + } + throw err + } finally { + clearTimeout(timer) + } + + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`AW API ${res.status} for ${url}: ${body}`) + } + + const json = (await res.json()) as { data: unknown[] } + return z.array(schema).parse(json.data) + }) +} +``` + +- [ ] **Step 2: Run existing AW API tests to verify no regression** + +There are no server-side AW API tests (only client-side). Run the poll-checker test instead: + +Run: `bun vitest run src/server/shared/jobs/poll-checker.test.ts` +Expected: Existing tests still pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/shared/lib/aw-api.ts +git commit -m "integrate withRetry into server AW API client" +``` + +--- + +### Task 4: Integrate withRetry into Server DIP API Client + +**Files:** +- Modify: `src/server/shared/lib/dip-api.ts` + +- [ ] **Step 1: Add withRetry import and wrap fetch helpers** + +In `src/server/shared/lib/dip-api.ts`, add the import at the top: + +```ts +import { withRetry } from "../../../shared/retry" +``` + +Replace the `dipFetch` function (lines 34-48) with: + +```ts +async function dipFetch(url: string): Promise { + return withRetry(async () => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), DIP_API_TIMEOUT_MS) + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "application/json", + Authorization: `ApiKey ${env.DIP_API_KEY}`, + }, + }) + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`DIP API ${res.status} for ${url}: ${body}`) + } + return res + } catch (err) { + clearTimeout(timer) + if (err instanceof DOMException && err.name === "AbortError") { + throw new Error(`DIP API request timed out for ${url}`) + } + throw err + } finally { + clearTimeout(timer) + } + }) +} +``` + +Also update `dipRequest` (lines 50-66) to remove the duplicate `!res.ok` check since `dipFetch` now handles it: + +```ts +async function dipRequest( + path: string, + params: Record, + schema: z.ZodType, +): Promise { + const url = new URL(`${DIP_API_BASE}/${path}`) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + + const res = await dipFetch(url.toString()) + const json = (await res.json()) as { documents: unknown[] } + return z.array(schema).parse(json.documents) +} +``` + +And update `fetchVorgangDetail` (lines 83-96) similarly: + +```ts +export async function fetchVorgangDetail( + vorgangsId: number, +): Promise { + const url = `${DIP_API_BASE}/vorgang/${vorgangsId}?format=json` + const res = await dipFetch(url) + const json = await res.json() + return vorgangDetailSchema.parse(json) +} +``` + +- [ ] **Step 2: Run full test suite to verify no regression** + +Run: `bun vitest run` +Expected: All existing tests pass. (Note: there are no dedicated server DIP API tests.) + +- [ ] **Step 3: Commit** + +```bash +git add src/server/shared/lib/dip-api.ts +git commit -m "integrate withRetry into server DIP API client" +``` + +--- + +### Task 5: Push Notification Error Handling + +**Files:** +- Modify: `src/server/shared/lib/web-push.ts` + +- [ ] **Step 1: Expand error handling in sendPushNotification** + +Replace the entire `sendPushNotification` function in `src/server/shared/lib/web-push.ts`: + +```ts +import { withRetry } from "../../../shared/retry" + +// ... keep existing webpush.setVapidDetails and type definitions ... + +export async function sendPushNotification( + sub: PushSubscription, + payload: PushPayload, +): Promise { + try { + await withRetry( + async () => { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + }, + JSON.stringify(payload), + ) + }, + { maxAttempts: 2, baseDelayMs: 1000 }, + ) + return true + } catch (err: unknown) { + const statusCode = (err as { statusCode?: number }).statusCode + if (statusCode === 410 || statusCode === 404) { + // subscription expired or invalid — caller should remove it + return false + } + if (statusCode === 429) { + console.warn( + `[web-push] rate limited for ${sub.endpoint}, skipping`, + ) + return true // preserve subscription, skip this notification + } + // other errors: log and skip, don't crash the caller + console.error(`[web-push] failed to send to ${sub.endpoint}:`, err) + return true // preserve subscription + } +} +``` + +Note: `return true` for 429 and unknown errors means "subscription is still valid, don't delete it." The caller only deletes on `return false`. + +- [ ] **Step 2: Run all tests to verify no regression** + +Run: `bun vitest run` +Expected: All existing tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/shared/lib/web-push.ts +git commit -m "harden web-push error handling: handle 429, 5xx, prevent job crashes" +``` + +--- + +### Task 6: Background Job Resilience — Poll Checker + +**Files:** +- Modify: `src/server/shared/jobs/poll-checker.ts` + +- [ ] **Step 1: Add try-catch to inner loops in processPoll** + +In `src/server/shared/jobs/poll-checker.ts`, the `processPoll` function (line 247) has two vulnerable inner loops. Wrap the mandate resolution loop (lines 300-305) and the vote comparison loop. + +Replace the per-device mandate resolution loop inside `processPoll` (lines 299-305): + +```ts + const matchedVotes: VoteDetail[] = [] + for (const pf of politicianFollows) { + try { + const mandateId = await resolveMandateId(pf.entityId) + if (mandateId === null) continue + const vote = votesByMandate.get(mandateId) + if (vote) matchedVotes.push(vote) + } catch (err) { + console.error( + JSON.stringify({ + job: "poll-checker", + action: "resolve-mandate", + politicianId: pf.entityId, + error: String(err), + }), + ) + } + } +``` + +Similarly, wrap the `sendVoteComparisonNotifications` inner loop's `resolveMandateId` call (line 145). Replace lines 144-155 in `sendVoteComparisonNotifications`: + +```ts + const comparisons: string[] = [] + for (const pf of politicianFollows) { + try { + const mandateId = await resolveMandateId(pf.entityId) + if (mandateId === null) continue + const repVote = votesByMandate.get(mandateId) + if (!repVote) continue + + const repLabel = VOTE_LABELS[repVote.vote] ?? repVote.vote + const fraction = repVote.fraction?.label + ? ` (${repVote.fraction.label})` + : "" + comparisons.push(`${repVote.mandate.label}${fraction}: ${repLabel}`) + } catch (err) { + console.error( + JSON.stringify({ + job: "poll-checker", + action: "resolve-mandate-comparison", + politicianId: pf.entityId, + error: String(err), + }), + ) + } + } +``` + +Also add structured logging to the existing top-level `processPoll` catch in `checkForNewPolls` (lines 233-235): + +```ts + } catch (err) { + console.error( + JSON.stringify({ + job: "poll-checker", + action: "process-poll", + pollId: poll.id, + error: String(err), + }), + ) + } +``` + +- [ ] **Step 2: Run poll-checker tests** + +Run: `bun vitest run src/server/shared/jobs/poll-checker.test.ts` +Expected: Existing tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/shared/jobs/poll-checker.ts +git commit -m "add try-catch to poll-checker inner loops, structured logging" +``` + +--- + +### Task 7: Background Job Resilience — Legislation Syncer + +**Files:** +- Modify: `src/server/shared/jobs/legislation-syncer.ts` + +- [ ] **Step 1: Add structured logging to existing try-catch** + +The legislation-syncer already has per-item try-catch (line 68). Update the log format. Replace the catch block (lines 68-73): + +```ts + } catch (err) { + console.error( + JSON.stringify({ + job: "legislation-syncer", + action: "fetch-vorgang-detail", + vorgangsId: vorgang.id, + error: String(err), + }), + ) + } +``` + +Also add structured format to the top-level `fetchUpcomingVorgaenge` call. Wrap lines 11-15: + +```ts + let vorgaenge: Awaited> + try { + vorgaenge = await fetchUpcomingVorgaenge() + } catch (err) { + console.error( + JSON.stringify({ + job: "legislation-syncer", + action: "fetch-upcoming", + error: String(err), + }), + ) + return + } + if (vorgaenge.length === 0) { + console.log("[legislation-syncer] no upcoming Vorgänge found") + return + } +``` + +- [ ] **Step 2: Run legislation-syncer tests** + +Run: `bun vitest run src/server/shared/jobs/legislation-syncer.test.ts` +Expected: Existing tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/shared/jobs/legislation-syncer.ts +git commit -m "add structured logging to legislation-syncer" +``` + +--- + +## Chunk 3: DIP API Key Proxy + +### Task 8: Backend DIP Proxy Routes + +**Files:** +- Modify: `src/server/features/legislation/router.ts` +- Modify: `src/client/shared/lib/dip-api.ts` +- Modify: `src/client/shared/lib/constants.ts` + +- [ ] **Step 1: Add proxy routes to legislation router** + +In `src/server/features/legislation/router.ts`, add the DIP proxy imports at the top: + +```ts +import { + fetchUpcomingVorgaenge, + fetchVorgangDetail, +} from "../../shared/lib/dip-api" +``` + +Add the proxy routes BEFORE the `/:id` param route (after the `/upcoming` route): + +```ts +legislationRouter.get("/dip-proxy/vorgaenge", async (c) => { + try { + const vorgaenge = await fetchUpcomingVorgaenge() + return c.json({ documents: vorgaenge }) + } catch (err) { + console.error("[dip-proxy] error fetching vorgaenge:", err) + return c.json({ error: "failed to fetch from DIP API" }, 502) + } +}) + +legislationRouter.get("/dip-proxy/vorgaenge/:vorgangsId", async (c) => { + const vorgangsId = Number(c.req.param("vorgangsId")) + if (Number.isNaN(vorgangsId)) + return c.json({ error: "invalid vorgangsId" }, 400) + + try { + const detail = await fetchVorgangDetail(vorgangsId) + return c.json(detail) + } catch (err) { + console.error( + `[dip-proxy] error fetching vorgang ${vorgangsId}:`, + err, + ) + return c.json({ error: "failed to fetch from DIP API" }, 502) + } +}) +``` + +- [ ] **Step 2: Update client DIP API to use backend proxy** + +Replace `src/client/shared/lib/dip-api.ts` entirely: + +```ts +import { z } from "zod" +import { BACKEND_URL } from "./constants" + +const AW_API_TIMEOUT_MS = 20_000 + +// --- Zod Schemas --- + +export const vorgangSchema = z.object({ + id: z.number(), + titel: z.string(), + beratungsstand: z.string().nullable().optional(), + datum: z.string().nullable().optional(), + vorgangstyp: z.string().nullable().optional(), + sachgebiet: z.array(z.string()).nullable().optional(), +}) + +// --- Types --- + +export type Vorgang = z.infer + +// --- Fetch helper (via backend proxy) --- + +export async function fetchUpcomingVorgaenge(): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), AW_API_TIMEOUT_MS) + try { + const res = await fetch( + `${BACKEND_URL}/legislation/dip-proxy/vorgaenge`, + { + signal: controller.signal, + headers: { Accept: "application/json" }, + }, + ) + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`DIP proxy ${res.status}: ${body}`) + } + const json = (await res.json()) as { documents: unknown[] } + return z.array(vorgangSchema).parse(json.documents) + } finally { + clearTimeout(timer) + } +} +``` + +- [ ] **Step 3: Remove DIP_API_KEY and DIP_API_BASE from client constants** + +In `src/client/shared/lib/constants.ts`, remove these two lines: + +```ts +export const DIP_API_BASE = "https://search.dip.bundestag.de/api/v1" +export const DIP_API_KEY = "GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp" +``` + +Keep `BUNDESTAG_WAHLPERIODE` only if it's used elsewhere in the client. Check first: + +Run: `grep -r "BUNDESTAG_WAHLPERIODE" src/client/ --include="*.ts" --include="*.tsx" | grep -v constants.ts | grep -v dip-api.ts` + +If no results, also remove the `BUNDESTAG_WAHLPERIODE` export from constants.ts. + +- [ ] **Step 4: Verify no client code references DIP_API_KEY** + +Run: `grep -r "DIP_API_KEY" src/client/` +Expected: No results. + +- [ ] **Step 5: Run all tests** + +Run: `bun vitest run` +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/server/features/legislation/router.ts src/client/shared/lib/dip-api.ts src/client/shared/lib/constants.ts +git commit -m "move DIP API key server-side: add proxy routes, update client to use proxy" +``` + +--- + +## Chunk 4: Client-Side Robustness + +### Task 9: Feed Assembly Partial Failure Handling + +**Files:** +- Modify: `src/client/features/feed/lib/assemble-feed.ts` +- Modify: `src/client/features/feed/hooks/use-feed.ts` + +- [ ] **Step 1: Replace Promise.all with Promise.allSettled in assemble-feed.ts** + +In `src/client/features/feed/lib/assemble-feed.ts`, add a `warnings` return field and update `assembleFeed`: + +Change the return type — add a warnings field: + +```ts +export interface FeedResult { + items: FeedItem[] + warnings: string[] +} +``` + +Replace `assembleFeed` function (lines 35-74): + +```ts +export async function assembleFeed( + followedTopicIDs: number[], + followedPoliticianIDs: number[], +): Promise { + const warnings: string[] = [] + + const [topicsResult, pollsResult] = await Promise.allSettled([ + fetchTopics(), + fetchPolls(150), + ]) + + const topics = + topicsResult.status === "fulfilled" ? topicsResult.value : [] + const polls = pollsResult.status === "fulfilled" ? pollsResult.value : [] + + if (topicsResult.status === "rejected") { + warnings.push("Themen konnten nicht geladen werden") + } + if (pollsResult.status === "rejected") { + warnings.push("Abstimmungen konnten nicht geladen werden") + } + + const topicMap = new Map(topics.map((t) => [t.id, t.label])) + + const topicSet = new Set(followedTopicIDs) + const filteredByTopics = + topicSet.size > 0 + ? polls.filter((p) => p.field_topics.some((t) => topicSet.has(t.id))) + : [] + + const { polls: politicianPolls, warnings: polWarnings } = + await fetchPollsForPoliticians(followedPoliticianIDs) + warnings.push(...polWarnings) + + const combined = new Map() + for (const p of [...filteredByTopics, ...politicianPolls]) + combined.set(p.id, p) + + const items: FeedItem[] = Array.from(combined.values()).map((poll) => ({ + id: `poll-${poll.id}`, + kind: "poll", + status: classifyPoll(poll), + title: poll.label, + url: poll.abgeordnetenwatch_url ?? null, + date: poll.field_poll_date, + topics: poll.field_topics.flatMap((t) => { + const label = t.label ?? topicMap.get(t.id) + return label ? [{ label, url: t.abgeordnetenwatch_url ?? null }] : [] + }), + source: "Bundestag", + })) + + items.sort((a, b) => { + if (a.date && b.date) return b.date.localeCompare(a.date) + if (!a.date && b.date) return 1 + if (a.date && !b.date) return -1 + return a.title.localeCompare(b.title) + }) + + return { items, warnings } +} +``` + +Update `fetchPollsForPoliticians` to use `Promise.allSettled`: + +```ts +async function fetchPollsForPoliticians( + politicianIDs: number[], +): Promise<{ polls: Poll[]; warnings: string[] }> { + if (politicianIDs.length === 0) return { polls: [], warnings: [] } + + const warnings: string[] = [] + + const mandateResults = await Promise.allSettled( + politicianIDs.map((pid) => fetchCandidacyMandates(pid)), + ) + const mandateIDs: number[] = [] + for (const r of mandateResults) { + if (r.status === "fulfilled") { + mandateIDs.push(...r.value.slice(0, 3).map((m) => m.id)) + } else { + warnings.push("Einige Abgeordneten-Daten konnten nicht geladen werden") + } + } + + const voteResults = await Promise.allSettled( + mandateIDs.map((mid) => fetchVotes(mid)), + ) + const pollIDSet = new Set() + for (const r of voteResults) { + if (r.status === "fulfilled") { + for (const v of r.value) { + if (v.poll?.id != null) pollIDSet.add(v.poll.id) + } + } + } + + const polls = await fetchPollsByIds(Array.from(pollIDSet)) + return { polls, warnings: [...new Set(warnings)] } +} +``` + +- [ ] **Step 2: Update use-feed.ts to handle FeedResult and await saveFeedCache** + +In `src/client/features/feed/hooks/use-feed.ts`, add a `warning` state and update the refresh function: + +Add `warning` state and update imports (line 1-5): + +```ts +import { useDb } from "@/shared/db/provider" +import { useFollows } from "@/shared/hooks/use-follows" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { FeedItem } from "../lib/assemble-feed" +import { assembleFeed } from "../lib/assemble-feed" +import { loadFeedCache, mergeFeedItems, saveFeedCache } from "../lib/feed-cache" +``` + +Add `warning` to state (after line 16): + +```ts +const [warning, setWarning] = useState(null) +``` + +Update the cache loading (lines 34-43) to catch errors: + +```ts + useEffect(() => { + loadFeedCache(db) + .then((cached) => { + if (cached && cached.items.length > 0) { + setItems(cached.items) + hasItemsRef.current = true + setLastUpdated(cached.updatedAt) + lastUpdatedRef.current = cached.updatedAt + } + }) + .catch((err) => { + console.error("[use-feed] failed to load cache:", err) + }) + }, [db]) +``` + +Update the refresh callback's try block (lines 60-70): + +```ts + try { + const result = await assembleFeed(topicIDs, politicianIDs) + if (result.warnings.length > 0) { + setWarning(result.warnings[0]) + } else { + setWarning(null) + } + setItems((prev) => { + const merged = mergeFeedItems(prev, result.items) + saveFeedCache(db, merged).catch((err) => { + console.error("[use-feed] failed to save cache:", err) + }) + hasItemsRef.current = merged.length > 0 + return merged + }) + const now = Date.now() + setLastUpdated(now) + lastUpdatedRef.current = now + } catch (e) { + setError(String(e)) + } finally { +``` + +Update the return (line 132): + +```ts + return { items, loading, refreshing, error, warning, lastUpdated, refresh } +``` + +- [ ] **Step 3: Update all callers of `assembleFeed` for new return type** + +The return type changed from `Promise` to `Promise`. Search for all callers: + +Run: `grep -r "assembleFeed" src/client/ --include="*.ts" --include="*.tsx" | grep -v node_modules` + +Update each caller to destructure `{ items }` (and optionally `warnings`) from the result: +- `src/client/features/feed/lib/assemble-feed.test.ts`: Replace `const feed = await assembleFeed(...)` with `const { items: feed } = await assembleFeed(...)` +- `src/client/features/feed/hooks/use-feed.ts`: Already updated in Step 2 +- Any other callers found by grep: update similarly + +- [ ] **Step 4: Run tests** + +Run: `bun vitest run src/client/features/feed/` +Expected: All feed tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/client/features/feed/lib/assemble-feed.ts src/client/features/feed/hooks/use-feed.ts src/client/features/feed/lib/assemble-feed.test.ts +git commit -m "feed assembly: handle partial failures, await cache saves, surface warnings" +``` + +--- + +### Task 10: Push/Follows Sync Debounce + +**Files:** +- Modify: `src/client/shared/hooks/use-push.ts` +- Create: `src/client/shared/hooks/use-push-sync.test.ts` + +- [ ] **Step 1: Add debounced sync to use-push.ts** + +Replace the follows sync effect (lines 63-66) in `src/client/shared/hooks/use-push.ts`: + +```ts + const syncTimerRef = useRef | null>(null) + const syncInFlightRef = useRef(false) + const pendingSyncRef = useRef(false) + + const debouncedSync = useCallback( + (deviceId: string, follows: Follow[]) => { + if (syncTimerRef.current) clearTimeout(syncTimerRef.current) + syncTimerRef.current = setTimeout(async () => { + if (syncInFlightRef.current) { + pendingSyncRef.current = true + return + } + syncInFlightRef.current = true + try { + await syncFollowsToBackend(deviceId, follows) + } finally { + syncInFlightRef.current = false + if (pendingSyncRef.current) { + pendingSyncRef.current = false + debouncedSync(deviceId, follows) + } + } + }, 300) + }, + [], + ) + + useEffect(() => { + if (!subscribed) return + debouncedSync(deviceId, follows) + }, [follows, subscribed, deviceId, debouncedSync]) +``` + +Also update `triggerPushSync` at the bottom (lines 71-79) to not fire-and-forget: + +```ts +export async function triggerPushSync( + deviceId: string, + follows: Follow[], + db: import("@electric-sql/pglite").PGlite, +) { + const enabled = await getPushState(db, "enabled") + if (enabled !== "true") return + await syncFollowsToBackend(deviceId, follows) +} +``` + +- [ ] **Step 2: Extract debounce logic into a testable utility** + +Create `src/client/shared/lib/debounced-sync.ts`: + +```ts +import { syncFollowsToBackend } from "./push-client" +import type { Follow } from "../hooks/use-follows" + +let timer: ReturnType | null = null +let inFlight = false +let pending: { deviceId: string; follows: Follow[] } | null = null + +export function debouncedSyncFollows( + deviceId: string, + follows: Follow[], + syncFn: typeof syncFollowsToBackend = syncFollowsToBackend, +) { + if (timer) clearTimeout(timer) + timer = setTimeout(async () => { + if (inFlight) { + pending = { deviceId, follows } + return + } + inFlight = true + try { + await syncFn(deviceId, follows) + } finally { + inFlight = false + if (pending) { + const next = pending + pending = null + debouncedSyncFollows(next.deviceId, next.follows, syncFn) + } + } + }, 300) +} + +export function resetDebouncedSync() { + if (timer) clearTimeout(timer) + timer = null + inFlight = false + pending = null +} +``` + +Then update `use-push.ts` to use it instead of inline logic. + +- [ ] **Step 3: Write debounce sync test** + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { debouncedSyncFollows, resetDebouncedSync } from "@/shared/lib/debounced-sync" + +describe("debouncedSyncFollows", () => { + beforeEach(() => { + vi.useFakeTimers() + resetDebouncedSync() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("debounces rapid sync calls", async () => { + const mockSync = vi.fn().mockResolvedValue(true) + + debouncedSyncFollows("dev1", [{ type: "topic", entity_id: 1, label: "A" }], mockSync) + debouncedSyncFollows("dev1", [{ type: "topic", entity_id: 1, label: "A" }, { type: "topic", entity_id: 2, label: "B" }], mockSync) + debouncedSyncFollows("dev1", [{ type: "topic", entity_id: 1, label: "A" }, { type: "topic", entity_id: 2, label: "B" }, { type: "topic", entity_id: 3, label: "C" }], mockSync) + + expect(mockSync).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + expect(mockSync).toHaveBeenCalledOnce() + expect(mockSync).toHaveBeenCalledWith("dev1", [ + { type: "topic", entity_id: 1, label: "A" }, + { type: "topic", entity_id: 2, label: "B" }, + { type: "topic", entity_id: 3, label: "C" }, + ]) + }) + + it("queues sync when one is in-flight", async () => { + let resolveFirst!: () => void + const firstCall = new Promise((r) => { resolveFirst = () => r(true) }) + const mockSync = vi.fn() + .mockReturnValueOnce(firstCall) + .mockResolvedValue(true) + + debouncedSyncFollows("dev1", [{ type: "topic", entity_id: 1, label: "A" }], mockSync) + await vi.advanceTimersByTimeAsync(300) + expect(mockSync).toHaveBeenCalledOnce() + + // while first is in-flight, trigger another sync + debouncedSyncFollows("dev1", [{ type: "topic", entity_id: 2, label: "B" }], mockSync) + await vi.advanceTimersByTimeAsync(300) + // should still only be 1 call (first is in-flight, second is pending) + expect(mockSync).toHaveBeenCalledOnce() + + // resolve first call, pending should fire + resolveFirst() + await vi.advanceTimersByTimeAsync(300) + expect(mockSync).toHaveBeenCalledTimes(2) + }) +}) +``` + +- [ ] **Step 4: Run tests** + +Run: `bun vitest run src/client/shared/lib/debounced-sync.test.ts` +Expected: Tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/client/shared/lib/debounced-sync.ts src/client/shared/lib/debounced-sync.test.ts src/client/shared/hooks/use-push.ts +git commit -m "debounce follows sync with in-flight guard" +``` + +--- + +### Task 11: IndexedDB Error Handling + +**Files:** +- Modify: `src/client/shared/db/follows.ts` +- Modify: `src/client/shared/db/feed-cache-db.ts` +- Modify: `src/client/shared/db/geo-cache-db.ts` +- Modify: `src/client/shared/db/push-state-db.ts` +- Modify: `src/client/shared/db/device.ts` + +- [ ] **Step 1: Wrap follows.ts query calls with try-catch** + +```ts +export async function getFollows(db: PGlite): Promise { + try { + const res = await db.query( + "SELECT type, entity_id, label FROM follows ORDER BY created_at", + ) + return res.rows + } catch (err) { + console.error("[db:follows] getFollows failed:", err) + return [] + } +} + +export async function addFollow( + db: PGlite, + type: "topic" | "politician", + entityId: number, + label: string, +): Promise { + try { + await db.query( + "INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + [type, entityId, label], + ) + } catch (err) { + console.error("[db:follows] addFollow failed:", err) + } +} + +export async function removeFollow( + db: PGlite, + type: "topic" | "politician", + entityId: number, +): Promise { + try { + await db.query("DELETE FROM follows WHERE type = $1 AND entity_id = $2", [ + type, + entityId, + ]) + } catch (err) { + console.error("[db:follows] removeFollow failed:", err) + } +} + +export async function removeAllFollows(db: PGlite): Promise { + try { + await db.query("DELETE FROM follows") + } catch (err) { + console.error("[db:follows] removeAllFollows failed:", err) + } +} + +export async function removeFollowsByType( + db: PGlite, + type: "topic" | "politician", +): Promise { + try { + await db.query("DELETE FROM follows WHERE type = $1", [type]) + } catch (err) { + console.error("[db:follows] removeFollowsByType failed:", err) + } +} +``` + +- [ ] **Step 2: Wrap feed-cache-db.ts with try-catch** + +```ts +export async function loadCachedFeed( + db: PGlite, + cacheKey = DEFAULT_CACHE_KEY, +): Promise<{ items: FeedItem[]; updatedAt: number } | null> { + try { + const res = await db.query( + "SELECT data, updated_at FROM feed_cache WHERE id = $1", + [cacheKey], + ) + if (res.rows.length === 0) return null + const row = res.rows[0] + return { + items: row.data.items, + updatedAt: new Date(row.updated_at).getTime(), + } + } catch (err) { + console.error("[db:feed-cache] loadCachedFeed failed:", err) + return null + } +} + +export async function saveCachedFeed( + db: PGlite, + items: FeedItem[], + cacheKey = DEFAULT_CACHE_KEY, +): Promise { + try { + await db.query( + `INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, now()) + ON CONFLICT (id) DO UPDATE SET data = $2, updated_at = now()`, + [cacheKey, JSON.stringify({ items })], + ) + } catch (err) { + console.error("[db:feed-cache] saveCachedFeed failed:", err) + } +} + +export async function clearCachedFeed( + db: PGlite, + cacheKey = DEFAULT_CACHE_KEY, +): Promise { + try { + await db.query("DELETE FROM feed_cache WHERE id = $1", [cacheKey]) + } catch (err) { + console.error("[db:feed-cache] clearCachedFeed failed:", err) + } +} +``` + +- [ ] **Step 3: Wrap geo-cache-db.ts with try-catch** + +```ts +export async function loadGeoCache( + db: PGlite, + bundesland: string, +): Promise | null> { + try { + const res = await db.query( + "SELECT data, cached_at FROM geo_cache WHERE bundesland = $1", + [bundesland], + ) + if (res.rows.length === 0) return null + const row = res.rows[0] + if (Date.now() - new Date(row.cached_at).getTime() > CACHE_TTL_MS) return null + return row.data + } catch (err) { + console.error("[db:geo-cache] loadGeoCache failed:", err) + return null + } +} + +export async function loadMostRecentGeoCache( + db: PGlite, +): Promise<{ data: Record; cachedAt: number } | null> { + try { + const res = await db.query( + "SELECT data, cached_at FROM geo_cache ORDER BY cached_at DESC LIMIT 1", + ) + if (res.rows.length === 0) return null + const row = res.rows[0] + const cachedAt = new Date(row.cached_at).getTime() + if (Date.now() - cachedAt > CACHE_TTL_MS) return null + return { data: row.data, cachedAt } + } catch (err) { + console.error("[db:geo-cache] loadMostRecentGeoCache failed:", err) + return null + } +} + +export async function saveGeoCache( + db: PGlite, + bundesland: string, + data: Record, +): Promise { + try { + await db.query( + `INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, now()) + ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = now()`, + [bundesland, JSON.stringify(data)], + ) + } catch (err) { + console.error("[db:geo-cache] saveGeoCache failed:", err) + } +} + +export async function clearGeoCache(db: PGlite): Promise { + try { + await db.query("DELETE FROM geo_cache") + } catch (err) { + console.error("[db:geo-cache] clearGeoCache failed:", err) + } +} +``` + +- [ ] **Step 4: Wrap push-state-db.ts with try-catch** + +```ts +export async function getPushState( + db: PGlite, + key: string, +): Promise { + try { + const res = await db.query<{ value: string }>( + "SELECT value FROM push_state WHERE key = $1", + [key], + ) + return res.rows.length > 0 ? res.rows[0].value : null + } catch (err) { + console.error("[db:push-state] getPushState failed:", err) + return null + } +} + +export async function setPushState( + db: PGlite, + key: string, + value: string, +): Promise { + try { + await db.query( + `INSERT INTO push_state (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2`, + [key, value], + ) + } catch (err) { + console.error("[db:push-state] setPushState failed:", err) + } +} + +export async function removePushState(db: PGlite, key: string): Promise { + try { + await db.query("DELETE FROM push_state WHERE key = $1", [key]) + } catch (err) { + console.error("[db:push-state] removePushState failed:", err) + } +} +``` + +- [ ] **Step 5: Wrap device.ts with try-catch (rethrow — device ID is critical)** + +```ts +export async function getOrCreateDeviceId(db: PGlite): Promise { + try { + const res = await db.query<{ id: string }>("SELECT id FROM device LIMIT 1") + if (res.rows.length > 0) return res.rows[0].id + + const id = generateUUID() + await db.query("INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", [ + id, + ]) + return id + } catch (err) { + console.error("[db:device] getOrCreateDeviceId failed:", err) + throw err + } +} +``` + +- [ ] **Step 6: Run all client tests** + +Run: `bun vitest run src/client/` +Expected: All tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/client/shared/db/follows.ts src/client/shared/db/feed-cache-db.ts src/client/shared/db/geo-cache-db.ts src/client/shared/db/push-state-db.ts src/client/shared/db/device.ts +git commit -m "wrap all IndexedDB operations with try-catch, return defaults on error" +``` + +--- + +### Task 12: Service Worker Hardening + +**Files:** +- Modify: `src/client/sw.ts` + +- [ ] **Step 1: Harden push and notification click handlers** + +Replace the push event handler (lines 59-73): + +```ts +self.addEventListener("push", (event) => { + if (!event.data) return + + let payload: PushPayload + try { + payload = event.data.json() as PushPayload + } catch { + console.error("[sw] failed to parse push payload") + return + } + + if (!payload.title) { + console.error("[sw] push payload missing title") + return + } + + event.waitUntil( + self.registration + .showNotification(payload.title, { + body: payload.body ?? "", + tag: payload.tag, + data: { url: payload.url }, + icon: "/agw/icons/icon-192.png", + badge: "/agw/icons/icon-192.png", + }) + .catch((err) => { + console.error("[sw] showNotification failed:", err) + }), + ) +}) +``` + +Replace the notification click handler (lines 75-94): + +```ts +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const data = event.notification.data + const url = + data && typeof data === "object" && typeof data.url === "string" + ? data.url + : "/agw/" + + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((windowClients) => { + for (const client of windowClients) { + if (client.url.includes("/agw/") && "focus" in client) { + return client.focus() + } + } + return self.clients.openWindow(url) + }) + .catch((err) => { + console.error("[sw] notificationclick handler failed:", err) + }), + ) +}) +``` + +- [ ] **Step 2: Run all tests** + +Run: `bun vitest run` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/client/sw.ts +git commit -m "harden service worker: validate push payload, catch notification errors" +``` + +--- + +### Task 12b: Geolocation Error Surfacing + +**Files:** +- Modify: `src/client/features/location/lib/geo.ts` +- Modify: Components that call geolocation (representatives, settings) + +- [ ] **Step 1: Update `detectFromCoords` to throw typed errors** + +In `src/client/features/location/lib/geo.ts`, the `detectFromCoords` function already throws on Nominatim error. The issue is that callers of the browser Geolocation API (in the UI components) don't surface errors. Read the representatives and settings route files to find where `navigator.geolocation.getCurrentPosition` is called, and ensure the error callback surfaces a user-visible message. + +Common geolocation errors to handle: +- `GeolocationPositionError.PERMISSION_DENIED` → "Standortzugriff wurde verweigert" +- `GeolocationPositionError.POSITION_UNAVAILABLE` → "Standort konnte nicht ermittelt werden" +- `GeolocationPositionError.TIMEOUT` → "Standortabfrage hat zu lange gedauert" + +Read each calling component before modifying. Add an error state that renders the appropriate message. + +- [ ] **Step 2: Run all tests** + +Run: `bun vitest run` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/client/features/location/ src/client/routes/ +git commit -m "surface geolocation errors as user-visible messages" +``` + +--- + +## Chunk 5: Error Boundaries & Route Wiring + +### Task 13: Wire Error Boundaries into Routes + +**Files:** +- Modify: `src/client/routes/__root.tsx` +- Modify: `src/client/routes/app/bundestag/index.tsx` +- Modify: `src/client/routes/app/landtag/index.tsx` +- Modify: `src/client/routes/app/legislation.$legislationId.tsx` +- Modify: `src/client/routes/app/politician.$politicianId.tsx` +- Modify: `src/client/routes/app/representatives.tsx` + +- [ ] **Step 1: Add error boundary to root route** + +In `src/client/routes/__root.tsx`, wrap the Outlet: + +```tsx +import { Outlet, createRootRoute } from "@tanstack/react-router" +import { ErrorBoundary } from "@/shared/components/error-boundary" + +function RootComponent() { + return ( +
+ + + +
+ ) +} + +export const Route = createRootRoute({ + component: RootComponent, +}) +``` + +- [ ] **Step 2: Add error boundaries to each major route** + +For each route file, wrap the component's return in `` with a context-specific fallback message. This is the same pattern for each: + +In `bundestag/index.tsx` and `landtag/index.tsx`: +```tsx +import { ErrorBoundary } from "@/shared/components/error-boundary" +// wrap the component return: + ( +
+

Abstimmungen konnten nicht geladen werden

+ +
+)}> + {/* existing component content */} +
+``` + +Apply the same pattern to `legislation.$legislationId.tsx` ("Gesetzgebung konnte nicht geladen werden"), `politician.$politicianId.tsx` ("Profil konnte nicht geladen werden"), and `representatives.tsx` ("Abgeordnete konnten nicht geladen werden"). + +Read each file before modifying to understand the exact component structure. + +- [ ] **Step 3: Audit loading/empty states in each route** + +For each data-fetching route, verify it handles three states: loading, empty (no data), and error. Read each route file. Where a state is missing, add it using existing Radix primitives (no new components). Common patterns: + +- Loading: show a centered spinner or "Laden..." text while data is null/loading +- Empty: show "Keine Ergebnisse" or similar when data is an empty array +- Error: already handled by the ErrorBoundary from Step 1-2 + +Routes to audit: `bundestag/index.tsx`, `landtag/index.tsx`, `legislation.$legislationId.tsx`, `politician.$politicianId.tsx`, `representatives.tsx`, `topics.tsx`. + +- [ ] **Step 4: Run all tests** + +Run: `bun vitest run` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/client/routes/ +git commit -m "wire error boundaries, loading/empty states into all major routes" +``` + +--- + +## Chunk 6: Test Coverage Expansion + +### Task 14: Server Integration Tests — Legislation Router + +**Files:** +- Create: `src/server/features/legislation/router.test.ts` + +- [ ] **Step 1: Write integration tests for legislation router** + +Use Hono's `app.request()` pattern. Mock the service layer with `vi.mock()`. + +```ts +import { describe, expect, it, vi, beforeEach } from "vitest" +import { Hono } from "hono" + +// mock service layer +vi.mock("./service", () => ({ + getUpcomingLegislation: vi.fn(), + getLegislation: vi.fn(), + getLegislationText: vi.fn(), + castVote: vi.fn(), + getUserVote: vi.fn(), + getLegislationResults: vi.fn(), + seedMockLegislation: vi.fn(), + deleteMockLegislation: vi.fn(), +})) + +vi.mock("../../shared/lib/dip-api", () => ({ + fetchUpcomingVorgaenge: vi.fn(), + fetchVorgangDetail: vi.fn(), +})) + +import { getUpcomingLegislation, getLegislation, castVote, getLegislationResults } from "./service" +import { fetchUpcomingVorgaenge, fetchVorgangDetail } from "../../shared/lib/dip-api" +import { legislationRouter } from "./router" + +const app = new Hono().route("/legislation", legislationRouter) + +describe("legislation router", () => { + beforeEach(() => { vi.clearAllMocks() }) + + it("GET /upcoming returns 200", async () => { + ;(getUpcomingLegislation as ReturnType).mockResolvedValue([{ id: 1, title: "Test" }]) + const res = await app.request("/legislation/upcoming") + expect(res.status).toBe(200) + }) + + it("GET /:id returns 404 when not found", async () => { + ;(getLegislation as ReturnType).mockResolvedValue(null) + const res = await app.request("/legislation/999") + expect(res.status).toBe(404) + }) + + it("GET /:id returns 400 for invalid id", async () => { + const res = await app.request("/legislation/abc") + expect(res.status).toBe(400) + }) + + it("POST /:id/vote returns 400 for invalid body", async () => { + const res = await app.request("/legislation/1/vote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + + it("GET /dip-proxy/vorgaenge returns 200", async () => { + ;(fetchUpcomingVorgaenge as ReturnType).mockResolvedValue([]) + const res = await app.request("/legislation/dip-proxy/vorgaenge") + expect(res.status).toBe(200) + }) + + it("GET /dip-proxy/vorgaenge/:id returns 502 on API failure", async () => { + ;(fetchVorgangDetail as ReturnType).mockRejectedValue(new Error("DIP API down")) + const res = await app.request("/legislation/dip-proxy/vorgaenge/123") + expect(res.status).toBe(502) + }) + + // add remaining tests for: POST /:id/vote success, GET /:id/results success/404, etc. +}) +``` + +- [ ] **Step 2: Run tests** + +Run: `bun vitest run src/server/features/legislation/router.test.ts` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/features/legislation/router.test.ts +git commit -m "add legislation router integration tests" +``` + +--- + +### Task 15: Server Integration Tests — Politician Router + +**Files:** +- Create: `src/server/features/politicians/router.test.ts` + +- [ ] **Step 1: Write integration tests** + +```ts +import { describe, expect, it, vi, beforeEach } from "vitest" +import { Hono } from "hono" + +vi.mock("./service", () => ({ + getPoliticianProfile: vi.fn(), +})) + +import { getPoliticianProfile } from "./service" +import { politicianRouter } from "./router" + +const app = new Hono().route("/politicians", politicianRouter) + +describe("politician router", () => { + beforeEach(() => { vi.clearAllMocks() }) + + it("GET /:id returns 200 with profile", async () => { + ;(getPoliticianProfile as ReturnType).mockResolvedValue({ id: 1, label: "Test" }) + const res = await app.request("/politicians/1") + expect(res.status).toBe(200) + }) + + it("GET /:id returns 404 when no mandates", async () => { + ;(getPoliticianProfile as ReturnType).mockResolvedValue(null) + const res = await app.request("/politicians/1") + expect(res.status).toBe(404) + }) + + it("GET /:id returns 400 for invalid id", async () => { + const res = await app.request("/politicians/abc") + expect(res.status).toBe(400) + }) + + it("GET /:id returns 400 for zero", async () => { + const res = await app.request("/politicians/0") + expect(res.status).toBe(400) + }) + + it("GET /:id returns 500 when service throws", async () => { + ;(getPoliticianProfile as ReturnType).mockRejectedValue(new Error("DB error")) + const res = await app.request("/politicians/1") + expect(res.status).toBe(500) + }) +}) +``` + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/server/features/politicians/router.test.ts` + +```bash +git add src/server/features/politicians/router.test.ts +git commit -m "add politician router integration tests" +``` + +--- + +### Task 16: Server Integration Tests — Push Router + +**Files:** +- Create: `src/server/features/push/router.test.ts` + +- [ ] **Step 1: Write integration tests** + +```ts +import { describe, expect, it, vi, beforeEach } from "vitest" +import { Hono } from "hono" + +vi.mock("./service", () => ({ + upsertSubscription: vi.fn(), + syncFollows: vi.fn(), + sendTestNotification: vi.fn(), + removeSubscription: vi.fn(), +})) + +import { upsertSubscription, sendTestNotification } from "./service" +import { pushRouter } from "./router" + +const app = new Hono().route("/push", pushRouter) + +const validSubscribeBody = { + device_id: "550e8400-e29b-41d4-a716-446655440000", + subscription: { + endpoint: "https://push.example.com/sub", + keys: { p256dh: "abc123", auth: "def456" }, + }, +} + +describe("push router", () => { + beforeEach(() => { vi.clearAllMocks() }) + + it("POST /subscribe returns 201", async () => { + const res = await app.request("/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(validSubscribeBody), + }) + expect(res.status).toBe(201) + }) + + it("POST /subscribe returns 400 for invalid body", async () => { + const res = await app.request("/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_id: "not-a-uuid" }), + }) + expect(res.status).toBe(400) + }) + + it("POST /test returns 410 for expired subscription", async () => { + ;(sendTestNotification as ReturnType).mockResolvedValue(false) + const res = await app.request("/push/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_id: "550e8400-e29b-41d4-a716-446655440000" }), + }) + expect(res.status).toBe(410) + }) + + it("DELETE /unsubscribe returns 200", async () => { + const res = await app.request("/push/unsubscribe", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_id: "550e8400-e29b-41d4-a716-446655440000" }), + }) + expect(res.status).toBe(200) + }) +}) +``` + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/server/features/push/router.test.ts` + +```bash +git add src/server/features/push/router.test.ts +git commit -m "add push router integration tests" +``` + +--- + +### Task 17: Service Worker Tests + +**Files:** +- Create: `src/client/sw.test.ts` + +- [ ] **Step 1: Write service worker tests** + +```ts +import { describe, expect, it, vi, beforeEach } from "vitest" + +// mock service worker globals +const mockShowNotification = vi.fn().mockResolvedValue(undefined) +const mockMatchAll = vi.fn().mockResolvedValue([]) +const mockOpenWindow = vi.fn().mockResolvedValue(null) + +const mockSelf = { + registration: { showNotification: mockShowNotification }, + clients: { matchAll: mockMatchAll, openWindow: mockOpenWindow }, + __WB_MANIFEST: [], + addEventListener: vi.fn(), + skipWaiting: vi.fn(), +} + +// capture event handlers registered via addEventListener +const handlers: Record = {} +mockSelf.addEventListener.mockImplementation((type: string, handler: Function) => { + handlers[type] = handler +}) + +// mock workbox modules +vi.mock("workbox-core", () => ({ clientsClaim: vi.fn() })) +vi.mock("workbox-expiration", () => ({ ExpirationPlugin: vi.fn() })) +vi.mock("workbox-precaching", () => ({ + cleanupOutdatedCaches: vi.fn(), + precacheAndRoute: vi.fn(), +})) +vi.mock("workbox-routing", () => ({ registerRoute: vi.fn() })) +vi.mock("workbox-strategies", () => ({ + CacheFirst: vi.fn(), + StaleWhileRevalidate: vi.fn(), +})) + +vi.stubGlobal("self", mockSelf) + +describe("service worker push handler", () => { + beforeEach(() => { + vi.clearAllMocks() + // re-import to re-register handlers + }) + + it("calls showNotification with valid payload", async () => { + // load sw module to register handlers + await import("./sw") + const pushHandler = handlers.push + if (!pushHandler) throw new Error("push handler not registered") + + const waitUntilFn = vi.fn() + pushHandler({ + data: { json: () => ({ title: "Test", body: "Hello" }) }, + waitUntil: waitUntilFn, + }) + + expect(waitUntilFn).toHaveBeenCalled() + }) + + it("ignores push event with no data", async () => { + await import("./sw") + const pushHandler = handlers.push + if (!pushHandler) throw new Error("push handler not registered") + + const waitUntilFn = vi.fn() + pushHandler({ data: null, waitUntil: waitUntilFn }) + + expect(waitUntilFn).not.toHaveBeenCalled() + }) +}) +``` + +Note: Service worker tests are challenging to mock correctly. The implementing agent should read `src/client/sw.ts` and adjust the mock setup as needed. The key tests to verify: +1. Valid payload → `showNotification` called with correct args +2. No data → early return +3. Malformed JSON → no crash +4. Missing title → no `showNotification` call +5. Notification click with URL → `openWindow` with that URL +6. Notification click without URL → `openWindow` with `/agw/` + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/client/sw.test.ts` + +```bash +git add src/client/sw.test.ts +git commit -m "add service worker push/notification click tests" +``` + +--- + +### Task 18: Client Hook Tests — use-push + +**Files:** +- Create: `src/client/shared/hooks/use-push.test.ts` + +- [ ] **Step 1: Write tests for use-push hook** + +Read `src/client/shared/hooks/use-push.ts` first. Use `@testing-library/react` `renderHook` with mocked dependencies (`push-client`, `push-state-db`, `use-device-id`, `use-follows`, `db/provider`). Tests: + +1. `subscribe()` with permission granted → sets subscribed to true, calls `subscribeToPush`, syncs follows +2. `subscribe()` with permission denied → returns false, subscribed stays false +3. `subscribe()` when `subscribeToPush` throws → loading resets to false, subscribed stays false +4. `unsubscribe()` → calls `unsubscribeFromPush`, sets subscribed to false, removes push state +5. Auto-syncs follows when subscribed and follows change (uses the debounced sync from Task 10) +6. Does not sync follows when not subscribed + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/client/shared/hooks/use-push.test.ts` + +```bash +git add src/client/shared/hooks/use-push.test.ts +git commit -m "add use-push hook tests: subscribe, unsubscribe, auto-sync" +``` + +--- + +### Task 19: Client Hook Tests — use-feed + +**Files:** +- Create: `src/client/features/feed/hooks/use-feed.test.ts` + +- [ ] **Step 1: Write tests for use-feed hook** + +Read `src/client/features/feed/hooks/use-feed.ts` first. Use `renderHook` with mocked `assembleFeed`, `loadFeedCache`, `saveFeedCache`, `useFollows`, and DB provider. Tests: + +1. Loads cache on mount → sets items from cache +2. Calls `assembleFeed` on mount when follows exist → sets items from result +3. Merges cached + fresh items (deduplication) +4. Sets `warning` when `assembleFeed` returns warnings +5. Sets `error` when `assembleFeed` throws +6. Handles cache load failure gracefully (logs, continues with empty) +7. Auto-refresh fires after interval (use `vi.useFakeTimers`) +8. Visibility change triggers refresh when stale (mock `document.hidden`) + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/client/features/feed/hooks/use-feed.test.ts` + +```bash +git add src/client/features/feed/hooks/use-feed.test.ts +git commit -m "add use-feed hook tests: refresh, cache, auto-refresh, visibility" +``` + +--- + +### Task 20: Client Hook Tests — use-device-id + +**Files:** +- Create: `src/client/shared/hooks/use-device-id.test.ts` + +- [ ] **Step 1: Write tests for use-device-id** + +Read `src/client/shared/hooks/use-device-id.ts` first to understand the implementation. Test: +1. Returns empty string initially, then a UUID after DB load +2. Returns cached value on subsequent calls +3. Handles DB error gracefully (if the hook has error handling; if not, just test the happy path) + +Use `@testing-library/react` `renderHook` with a mock PGlite DB provider. + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/client/shared/hooks/use-device-id.test.ts` + +```bash +git add src/client/shared/hooks/use-device-id.test.ts +git commit -m "add use-device-id hook tests" +``` + +--- + +### Task 21: Background Job Test Expansion — Poll Checker + +**Files:** +- Modify: `src/server/shared/jobs/poll-checker.test.ts` + +- [ ] **Step 1: Add tests for inner loop resilience** + +Read the existing `poll-checker.test.ts` to understand the current mock pattern. Add tests: + +1. `resolveMandateId` failure for one politician doesn't break other politicians in the same device's notification +2. `sendPushNotification` returning false (expired) triggers subscription deletion +3. Notification payload construction includes correct vote labels and fraction info +4. `sendVoteComparisonNotifications` skips devices with no push subscription +5. `linkPollToLegislation` doesn't crash when no unlinked legislation exists + +Mock `db` queries and `fetchCandidacyMandates`, `fetchVotesByPoll`, `sendPushNotification` using the existing mock pattern. + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/server/shared/jobs/poll-checker.test.ts` + +```bash +git add src/server/shared/jobs/poll-checker.test.ts +git commit -m "expand poll-checker tests: inner loop resilience, notification payloads" +``` + +--- + +### Task 22: Background Job Test Expansion — Legislation Syncer + +**Files:** +- Modify: `src/server/shared/jobs/legislation-syncer.test.ts` + +- [ ] **Step 1: Add tests for error handling paths** + +Read the existing test file first. Add tests: + +1. `fetchUpcomingVorgaenge` failure → logs structured error, returns early +2. Individual `fetchVorgangDetail` failure → continues with remaining vorgänge +3. All vorgänge already cached → logs "all cached", returns early +4. Successful insert of new legislation text with correct fields + +- [ ] **Step 2: Run tests, commit** + +Run: `bun vitest run src/server/shared/jobs/legislation-syncer.test.ts` + +```bash +git add src/server/shared/jobs/legislation-syncer.test.ts +git commit -m "expand legislation-syncer tests: error handling, structured logging" +``` + +--- + +### Task 23: Final Verification + +- [ ] **Step 1: Run full test suite** + +Run: `bun vitest run` +Expected: All tests pass, zero failures. + +- [ ] **Step 2: Run type check** + +Run: `bun tsc --noEmit` +Expected: No type errors. + +- [ ] **Step 3: Run linter** + +Run: `bun biome check src/` +Expected: No errors (warnings acceptable). + +- [ ] **Step 4: Verify no DIP_API_KEY in client bundle** + +Run: `grep -r "GmEPb1B" src/client/` +Expected: No results.