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
assembleFeedfor 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: Replaceconst feed = await assembleFeed(...)withconst { 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
detectFromCoordsto 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:
- Valid payload →
showNotificationcalled with correct args - No data → early return
- Malformed JSON → no crash
- Missing title → no
showNotificationcall - Notification click with URL →
openWindowwith that URL - Notification click without URL →
openWindowwith/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:
subscribe()with permission granted → sets subscribed to true, callssubscribeToPush, syncs followssubscribe()with permission denied → returns false, subscribed stays falsesubscribe()whensubscribeToPushthrows → loading resets to false, subscribed stays falseunsubscribe()→ callsunsubscribeFromPush, sets subscribed to false, removes push state- Auto-syncs follows when subscribed and follows change (uses the debounced sync from Task 10)
- 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:
- Loads cache on mount → sets items from cache
- Calls
assembleFeedon mount when follows exist → sets items from result - Merges cached + fresh items (deduplication)
- Sets
warningwhenassembleFeedreturns warnings - Sets
errorwhenassembleFeedthrows - Handles cache load failure gracefully (logs, continues with empty)
- Auto-refresh fires after interval (use
vi.useFakeTimers) - 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:
- Returns empty string initially, then a UUID after DB load
- Returns cached value on subsequent calls
- 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:
resolveMandateIdfailure for one politician doesn't break other politicians in the same device's notificationsendPushNotificationreturning false (expired) triggers subscription deletion- Notification payload construction includes correct vote labels and fraction info
sendVoteComparisonNotificationsskips devices with no push subscriptionlinkPollToLegislationdoesn'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:
fetchUpcomingVorgaengefailure → logs structured error, returns early- Individual
fetchVorgangDetailfailure → continues with remaining vorgänge - All vorgänge already cached → logs "all cached", returns early
- 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.