Files
agw/docs/superpowers/plans/2026-03-11-robustness-plan.md
2026-03-11 13:00:16 +01:00

61 KiB

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

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<T>(
	fn: () => Promise<T>,
	opts?: RetryOptions,
): Promise<T> {
	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
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
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

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<Props, State> {
	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 (
				<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
					<p className="text-lg font-medium">Etwas ist schiefgelaufen</p>
					<p className="text-sm text-gray-500">{this.state.error.message}</p>
					<button
						type="button"
						onClick={this.reset}
						className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
					>
						Erneut versuchen
					</button>
				</div>
			)
		}
		return this.props.children
	}
}
  • Step 2: Write tests for error boundary
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 <div>child content</div>
}

describe("ErrorBoundary", () => {
	it("renders children when no error", () => {
		render(
			<ErrorBoundary>
				<ThrowingChild shouldThrow={false} />
			</ErrorBoundary>,
		)
		expect(screen.getByText("child content")).toBeTruthy()
	})

	it("renders default fallback on error", () => {
		const spy = vi.spyOn(console, "error").mockImplementation(() => {})
		render(
			<ErrorBoundary>
				<ThrowingChild shouldThrow={true} />
			</ErrorBoundary>,
		)
		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(
			<ErrorBoundary
				fallback={({ error }) => <div>custom: {error.message}</div>}
			>
				<ThrowingChild shouldThrow={true} />
			</ErrorBoundary>,
		)
		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 <div>recovered</div>
		}

		render(
			<ErrorBoundary>
				<Child />
			</ErrorBoundary>,
		)
		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
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:

import { withRetry } from "../../../shared/retry"

Replace the request function (lines 88-115) with:

async function request<T>(
	path: string,
	params: Record<string, string>,
	schema: z.ZodType<T>,
): Promise<T[]> {
	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
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:

import { withRetry } from "../../../shared/retry"

Replace the dipFetch function (lines 34-48) with:

async function dipFetch(url: string): Promise<Response> {
	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:

async function dipRequest<T>(
	path: string,
	params: Record<string, string>,
	schema: z.ZodType<T>,
): Promise<T[]> {
	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:

export async function fetchVorgangDetail(
	vorgangsId: number,
): Promise<VorgangDetail> {
	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
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:

import { withRetry } from "../../../shared/retry"

// ... keep existing webpush.setVapidDetails and type definitions ...

export async function sendPushNotification(
	sub: PushSubscription,
	payload: PushPayload,
): Promise<boolean> {
	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
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):

		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:

		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):

		} 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
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):

		} 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:

	let vorgaenge: Awaited<ReturnType<typeof fetchUpcomingVorgaenge>>
	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
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:

import {
	fetchUpcomingVorgaenge,
	fetchVorgangDetail,
} from "../../shared/lib/dip-api"

Add the proxy routes BEFORE the /:id param route (after the /upcoming route):

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:

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<typeof vorgangSchema>

// --- Fetch helper (via backend proxy) ---

export async function fetchUpcomingVorgaenge(): Promise<Vorgang[]> {
	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:

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
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:

export interface FeedResult {
	items: FeedItem[]
	warnings: string[]
}

Replace assembleFeed function (lines 35-74):

export async function assembleFeed(
	followedTopicIDs: number[],
	followedPoliticianIDs: number[],
): Promise<FeedResult> {
	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<number, Poll>()
	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:

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<number>()
	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):

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):

const [warning, setWarning] = useState<string | null>(null)

Update the cache loading (lines 34-43) to catch errors:

	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):

			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):

	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<FeedItem[]> to Promise<FeedResult>. 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
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:

	const syncTimerRef = useRef<ReturnType<typeof setTimeout> | 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:

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:

import { syncFollowsToBackend } from "./push-client"
import type { Follow } from "../hooks/use-follows"

let timer: ReturnType<typeof setTimeout> | 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
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<boolean>((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
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

export async function getFollows(db: PGlite): Promise<Follow[]> {
	try {
		const res = await db.query<Follow>(
			"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<void> {
	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<void> {
	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<void> {
	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<void> {
	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
export async function loadCachedFeed(
	db: PGlite,
	cacheKey = DEFAULT_CACHE_KEY,
): Promise<{ items: FeedItem[]; updatedAt: number } | null> {
	try {
		const res = await db.query<CacheRow>(
			"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<void> {
	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<void> {
	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
export async function loadGeoCache(
	db: PGlite,
	bundesland: string,
): Promise<Record<string, unknown> | null> {
	try {
		const res = await db.query<GeoResultRow>(
			"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<string, unknown>; cachedAt: number } | null> {
	try {
		const res = await db.query<GeoResultRow>(
			"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<string, unknown>,
): Promise<void> {
	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<void> {
	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
export async function getPushState(
	db: PGlite,
	key: string,
): Promise<string | null> {
	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<void> {
	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<void> {
	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)
export async function getOrCreateDeviceId(db: PGlite): Promise<string> {
	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
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):

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):

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
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
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:

import { Outlet, createRootRoute } from "@tanstack/react-router"
import { ErrorBoundary } from "@/shared/components/error-boundary"

function RootComponent() {
	return (
		<div className="h-dvh">
			<ErrorBoundary>
				<Outlet />
			</ErrorBoundary>
		</div>
	)
}

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 <ErrorBoundary> with a context-specific fallback message. This is the same pattern for each:

In bundestag/index.tsx and landtag/index.tsx:

import { ErrorBoundary } from "@/shared/components/error-boundary"
// wrap the component return:
<ErrorBoundary fallback={({ error, reset }) => (
	<div className="p-4 text-center">
		<p>Abstimmungen konnten nicht geladen werden</p>
		<button onClick={reset}>Erneut versuchen</button>
	</div>
)}>
	{/* existing component content */}
</ErrorBoundary>

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
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().

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
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

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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

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

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<typeof vi.fn>).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

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

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<string, Function> = {}
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

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

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

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

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

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

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.