# Testing Improvements Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add unit tests for new server code (parseId, rate limiter, param validation routes), set up Playwright E2E testing with a seeded database, and write E2E tests covering dashboard, collection, threads, auth, and error handling. **Architecture:** Unit tests use existing Bun test runner + Hono `app.request()` pattern. E2E tests use Playwright against a real server with a pre-seeded SQLite database. A global-setup script creates the test DB using Drizzle migrations + direct inserts before Playwright runs. **Tech Stack:** Bun test runner, Playwright (Chromium only), Drizzle ORM migrations, Hono --- ### Task 1: Unit Tests for parseId **Files:** - Create: `tests/lib/params.test.ts` - [ ] **Step 1: Write tests** Create `tests/lib/params.test.ts`: ```ts import { describe, expect, it } from "bun:test"; import { parseId } from "../../src/server/lib/params"; describe("parseId", () => { it("returns number for valid positive integers", () => { expect(parseId("1")).toBe(1); expect(parseId("42")).toBe(42); expect(parseId("999")).toBe(999); }); it("returns null for zero", () => { expect(parseId("0")).toBeNull(); }); it("returns null for negative numbers", () => { expect(parseId("-1")).toBeNull(); expect(parseId("-100")).toBeNull(); }); it("returns null for decimals", () => { expect(parseId("1.5")).toBeNull(); expect(parseId("3.14")).toBeNull(); }); it("returns null for non-numeric strings", () => { expect(parseId("abc")).toBeNull(); expect(parseId("")).toBeNull(); expect(parseId("hello")).toBeNull(); expect(parseId("12abc")).toBeNull(); }); it("returns null for special values", () => { expect(parseId("NaN")).toBeNull(); expect(parseId("Infinity")).toBeNull(); expect(parseId("-Infinity")).toBeNull(); }); }); ``` - [ ] **Step 2: Run tests** Run: `bun test tests/lib/params.test.ts` Expected: All tests pass. - [ ] **Step 3: Commit** ```bash git add tests/lib/params.test.ts git commit -m "test: add unit tests for parseId helper" ``` --- ### Task 2: Unit Tests for Rate Limiter **Files:** - Modify: `src/server/middleware/rateLimit.ts` (add test reset function) - Create: `tests/middleware/rateLimit.test.ts` - [ ] **Step 1: Add test reset function to rate limiter** In `src/server/middleware/rateLimit.ts`, add at the end of the file: ```ts /** @internal — only for testing */ export function _resetForTesting() { store.clear(); } ``` - [ ] **Step 2: Write tests** Create `tests/middleware/rateLimit.test.ts`: ```ts import { beforeEach, describe, expect, it } from "bun:test"; import { Hono } from "hono"; import { _resetForTesting, rateLimit } from "../../src/server/middleware/rateLimit"; function createApp() { const app = new Hono(); app.post("/login", rateLimit, (c) => c.json({ ok: true })); app.post("/setup", rateLimit, (c) => c.json({ ok: true })); return app; } function makeRequest(app: Hono, path: string, ip = "127.0.0.1") { return app.request(path, { method: "POST", headers: { "x-forwarded-for": ip }, }); } describe("rateLimit middleware", () => { beforeEach(() => { _resetForTesting(); }); it("allows first request through", async () => { const app = createApp(); const res = await makeRequest(app, "/login"); expect(res.status).toBe(200); }); it("allows up to 5 requests", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { const res = await makeRequest(app, "/login"); expect(res.status).toBe(200); } }); it("returns 429 after 5 requests", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { await makeRequest(app, "/login"); } const res = await makeRequest(app, "/login"); expect(res.status).toBe(429); const body = await res.json(); expect(body.error).toBe("Too many attempts. Try again later."); }); it("includes Retry-After header on 429", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { await makeRequest(app, "/login"); } const res = await makeRequest(app, "/login"); expect(res.status).toBe(429); const retryAfter = res.headers.get("Retry-After"); expect(retryAfter).toBeTruthy(); expect(Number(retryAfter)).toBeGreaterThan(0); }); it("tracks different IPs independently", async () => { const app = createApp(); // Fill up IP 1 for (let i = 0; i < 5; i++) { await makeRequest(app, "/login", "10.0.0.1"); } // IP 1 is blocked const blocked = await makeRequest(app, "/login", "10.0.0.1"); expect(blocked.status).toBe(429); // IP 2 still works const allowed = await makeRequest(app, "/login", "10.0.0.2"); expect(allowed.status).toBe(200); }); it("tracks different paths independently", async () => { const app = createApp(); // Fill up /login for (let i = 0; i < 5; i++) { await makeRequest(app, "/login"); } const blockedLogin = await makeRequest(app, "/login"); expect(blockedLogin.status).toBe(429); // /setup still works const allowedSetup = await makeRequest(app, "/setup"); expect(allowedSetup.status).toBe(200); }); }); ``` - [ ] **Step 3: Run tests** Run: `bun test tests/middleware/rateLimit.test.ts` Expected: All tests pass. - [ ] **Step 4: Run full test suite** Run: `bun test` Expected: All tests pass (previous 183 + new ones). - [ ] **Step 5: Commit** ```bash git add src/server/middleware/rateLimit.ts tests/middleware/rateLimit.test.ts git commit -m "test: add unit tests for rate limiter middleware" ``` --- ### Task 3: Route-Level Tests for Invalid ID Params **Files:** - Create: `tests/routes/params.test.ts` - [ ] **Step 1: Write tests** Create `tests/routes/params.test.ts`: ```ts import { beforeEach, describe, expect, it } from "bun:test"; import { Hono } from "hono"; import { categoryRoutes } from "../../src/server/routes/categories"; import { itemRoutes } from "../../src/server/routes/items"; import { setupRoutes } from "../../src/server/routes/setups"; import { threadRoutes } from "../../src/server/routes/threads"; import { createTestDb } from "../helpers/db"; function createTestApp() { const db = createTestDb(); const app = new Hono(); app.use("*", async (c, next) => { c.set("db", db); await next(); }); app.route("/api/items", itemRoutes); app.route("/api/categories", categoryRoutes); app.route("/api/threads", threadRoutes); app.route("/api/setups", setupRoutes); return app; } describe("Invalid ID parameter handling", () => { let app: Hono; beforeEach(() => { app = createTestApp(); }); describe("items", () => { it("GET /api/items/abc returns 400", async () => { const res = await app.request("/api/items/abc"); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toContain("Invalid"); }); it("GET /api/items/0 returns 400", async () => { const res = await app.request("/api/items/0"); expect(res.status).toBe(400); }); it("GET /api/items/-1 returns 400", async () => { const res = await app.request("/api/items/-1"); expect(res.status).toBe(400); }); }); describe("categories", () => { it("DELETE /api/categories/abc returns 400", async () => { const res = await app.request("/api/categories/abc", { method: "DELETE", }); expect(res.status).toBe(400); }); }); describe("threads", () => { it("GET /api/threads/abc returns 400", async () => { const res = await app.request("/api/threads/abc"); expect(res.status).toBe(400); }); it("GET /api/threads/1.5 returns 400", async () => { const res = await app.request("/api/threads/1.5"); expect(res.status).toBe(400); }); }); describe("setups", () => { it("GET /api/setups/abc returns 400", async () => { const res = await app.request("/api/setups/abc"); expect(res.status).toBe(400); }); it("GET /api/setups/0 returns 400", async () => { const res = await app.request("/api/setups/0"); expect(res.status).toBe(400); }); }); }); ``` - [ ] **Step 2: Run tests** Run: `bun test tests/routes/params.test.ts` Expected: All tests pass. - [ ] **Step 3: Commit** ```bash git add tests/routes/params.test.ts git commit -m "test: add route-level tests for invalid ID parameter handling" ``` --- ### Task 4: Install Playwright and Create Config **Files:** - Modify: `package.json` (add dep + scripts) - Create: `playwright.config.ts` - Modify: `.gitignore` - [ ] **Step 1: Install Playwright** ```bash bun add -d @playwright/test bunx playwright install chromium ``` - [ ] **Step 2: Create playwright.config.ts** Create `playwright.config.ts` at project root: ```ts import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", fullyParallel: false, retries: 0, workers: 1, reporter: "list", globalSetup: "./e2e/global-setup.ts", use: { baseURL: "http://localhost:3000", trace: "on-first-retry", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], webServer: { command: "DATABASE_PATH=./e2e/test.db bun run dev:server", port: 3000, reuseExistingServer: !process.env.CI, timeout: 10000, }, }); ``` - [ ] **Step 3: Add scripts to package.json** Add these to the `"scripts"` section in `package.json`: ```json "test:e2e": "bunx playwright test", "test:e2e:ui": "bunx playwright test --ui" ``` - [ ] **Step 4: Update .gitignore** Append to `.gitignore`: ``` # Playwright e2e/test.db test-results/ playwright-report/ ``` - [ ] **Step 5: Run lint** Run: `bun run lint` Expected: Clean. - [ ] **Step 6: Commit** ```bash git add package.json bun.lock playwright.config.ts .gitignore git commit -m "chore: install Playwright and add E2E test configuration" ``` --- ### Task 5: E2E Database Seed and Global Setup **Files:** - Create: `e2e/seed.ts` - Create: `e2e/global-setup.ts` - [ ] **Step 1: Create seed script** Create `e2e/seed.ts`: ```ts import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import * as schema from "../src/db/schema"; const DB_PATH = "./e2e/test.db"; export async function seedTestDatabase() { // Remove old test DB if it exists try { await Bun.file(DB_PATH).exists() && (await import("node:fs/promises")).then((fs) => fs.unlink(DB_PATH)); } catch { // File doesn't exist, that's fine } const sqlite = new Database(DB_PATH); sqlite.run("PRAGMA journal_mode = WAL"); sqlite.run("PRAGMA foreign_keys = ON"); const db = drizzle(sqlite, { schema }); // Apply all migrations migrate(db, { migrationsFolder: "./drizzle" }); // ── Seed Categories ── const [uncategorized] = db .insert(schema.categories) .values({ name: "Uncategorized", icon: "package" }) .returning() .all(); const [shelter] = db .insert(schema.categories) .values({ name: "Shelter", icon: "tent" }) .returning() .all(); const [sleep] = db .insert(schema.categories) .values({ name: "Sleep System", icon: "moon" }) .returning() .all(); const [cook] = db .insert(schema.categories) .values({ name: "Cook Kit", icon: "flame" }) .returning() .all(); // ── Seed Items ── const tent = db .insert(schema.items) .values({ name: "Zpacks Duplex", weightGrams: 539, priceCents: 67900, categoryId: shelter.id, notes: "DCF shelter, 2-person", }) .returning() .get(); const tarp = db .insert(schema.items) .values({ name: "Borah Gear Tarp", weightGrams: 156, priceCents: 11000, categoryId: shelter.id, }) .returning() .get(); const quilt = db .insert(schema.items) .values({ name: "Enlightened Equipment Enigma 20", weightGrams: 595, priceCents: 34000, categoryId: sleep.id, notes: "20F quilt", }) .returning() .get(); const pad = db .insert(schema.items) .values({ name: "Therm-a-Rest NeoAir XLite", weightGrams: 354, priceCents: 20999, categoryId: sleep.id, }) .returning() .get(); const stove = db .insert(schema.items) .values({ name: "BRS-3000T Stove", weightGrams: 25, priceCents: 2000, categoryId: cook.id, }) .returning() .get(); const pot = db .insert(schema.items) .values({ name: "Toaks 750ml Pot", weightGrams: 103, priceCents: 3000, categoryId: cook.id, }) .returning() .get(); // ── Seed Active Thread with 3 Candidates ── const activeThread = db .insert(schema.threads) .values({ name: "New Backpack", status: "active", categoryId: uncategorized.id, }) .returning() .get(); db.insert(schema.threadCandidates) .values({ threadId: activeThread.id, name: "ULA Circuit", weightGrams: 1077, priceCents: 27500, categoryId: uncategorized.id, pros: "Great hip belt\nLarge capacity", cons: "Heavier than competitors", sortOrder: 1000, status: "researching", }) .run(); db.insert(schema.threadCandidates) .values({ threadId: activeThread.id, name: "Gossamer Gear Mariposa", weightGrams: 737, priceCents: 28500, categoryId: uncategorized.id, pros: "Very lightweight\nGood ventilation", cons: "Smaller hip belt pockets", sortOrder: 2000, status: "researching", }) .run(); db.insert(schema.threadCandidates) .values({ threadId: activeThread.id, name: "Granite Gear Crown2 38", weightGrams: 850, priceCents: 18000, categoryId: uncategorized.id, sortOrder: 3000, status: "ordered", }) .run(); // ── Seed Resolved Thread ── const resolvedThread = db .insert(schema.threads) .values({ name: "Camp Stove", status: "resolved", categoryId: cook.id, resolvedCandidateId: 1, }) .returning() .get(); db.insert(schema.threadCandidates) .values({ threadId: resolvedThread.id, name: "BRS-3000T", weightGrams: 25, priceCents: 2000, categoryId: cook.id, sortOrder: 1000, status: "arrived", }) .run(); // ── Seed Setup with Items ── const setup = db .insert(schema.setups) .values({ name: "Weekend Overnighter" }) .returning() .get(); db.insert(schema.setupItems) .values([ { setupId: setup.id, itemId: tent.id, classification: "base" }, { setupId: setup.id, itemId: quilt.id, classification: "base" }, { setupId: setup.id, itemId: pad.id, classification: "base" }, { setupId: setup.id, itemId: stove.id, classification: "consumable" }, ]) .run(); // ── Seed User ── const passwordHash = await Bun.password.hash("password123"); db.insert(schema.users) .values({ username: "admin", passwordHash }) .run(); // ── Seed Settings ── db.insert(schema.settings) .values([ { key: "weightUnit", value: "g" }, { key: "currency", value: "USD" }, { key: "onboardingComplete", value: "true" }, ]) .run(); sqlite.close(); console.log("E2E test database seeded at", DB_PATH); } ``` - [ ] **Step 2: Create global-setup** Create `e2e/global-setup.ts`: ```ts import { seedTestDatabase } from "./seed"; export default async function globalSetup() { await seedTestDatabase(); } ``` - [ ] **Step 3: Verify seed works** Run: `bun run e2e/global-setup.ts` Expected: Prints "E2E test database seeded at ./e2e/test.db" and the file exists. Then clean up: `rm -f e2e/test.db` - [ ] **Step 4: Commit** ```bash git add e2e/seed.ts e2e/global-setup.ts git commit -m "test: add E2E database seed and Playwright global setup" ``` --- ### Task 6: E2E Tests — Dashboard and Collection **Files:** - Create: `e2e/dashboard.spec.ts` - Create: `e2e/collection.spec.ts` - [ ] **Step 1: Create dashboard tests** Create `e2e/dashboard.spec.ts`: ```ts import { expect, test } from "@playwright/test"; test.describe("Dashboard", () => { test("loads and shows summary cards", async ({ page }) => { await page.goto("/"); await expect(page.locator("text=GearBox")).toBeVisible(); // Should show item count (we seeded 6 items) await expect(page.locator("text=6")).toBeVisible(); }); test("has navigation to collection", async ({ page }) => { await page.goto("/"); // Click on a dashboard card or link that goes to collection const collectionLink = page.locator('a[href*="collection"]').first(); if (await collectionLink.isVisible()) { await collectionLink.click(); await expect(page).toHaveURL(/collection/); } }); }); ``` - [ ] **Step 2: Create collection tests** Create `e2e/collection.spec.ts`: ```ts import { expect, test } from "@playwright/test"; test.describe("Collection", () => { test("gear tab shows items grouped by category", async ({ page }) => { await page.goto("/collection?tab=gear"); // Should see seeded items await expect(page.locator("text=Zpacks Duplex")).toBeVisible(); await expect(page.locator("text=BRS-3000T Stove")).toBeVisible(); // Should see category headers await expect(page.locator("text=Shelter")).toBeVisible(); await expect(page.locator("text=Cook Kit")).toBeVisible(); }); test("search filters items by name", async ({ page }) => { await page.goto("/collection?tab=gear"); const searchInput = page.locator('input[placeholder*="Search"]'); await searchInput.fill("Zpacks"); // Should show matching item await expect(page.locator("text=Zpacks Duplex")).toBeVisible(); // Should hide non-matching items await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible(); }); test("tab switching works", async ({ page }) => { await page.goto("/collection?tab=gear"); await expect(page.locator("text=Zpacks Duplex")).toBeVisible(); // Switch to planning tab await page.goto("/collection?tab=planning"); await expect(page.locator("text=Planning Threads")).toBeVisible(); await expect(page.locator("text=New Backpack")).toBeVisible(); // Switch to setups tab await page.goto("/collection?tab=setups"); await expect(page.locator("text=Weekend Overnighter")).toBeVisible(); }); test("category filter dropdown works", async ({ page }) => { await page.goto("/collection?tab=gear"); // Open category filter const filterButton = page.locator("text=All categories"); await filterButton.click(); // Select "Shelter" await page.locator("li").filter({ hasText: "Shelter" }).click(); // Should show only shelter items await expect(page.locator("text=Zpacks Duplex")).toBeVisible(); await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible(); }); }); ``` - [ ] **Step 3: Run E2E tests** Run: `bun run test:e2e` Expected: All tests pass. If any fail due to selector issues, adjust selectors based on actual DOM. - [ ] **Step 4: Commit** ```bash git add e2e/dashboard.spec.ts e2e/collection.spec.ts git commit -m "test: add E2E tests for dashboard and collection views" ``` --- ### Task 7: E2E Tests — Threads, Auth, Error Handling **Files:** - Create: `e2e/threads.spec.ts` - Create: `e2e/auth.spec.ts` - Create: `e2e/error-handling.spec.ts` - [ ] **Step 1: Create threads tests** Create `e2e/threads.spec.ts`: ```ts import { expect, test } from "@playwright/test"; test.describe("Threads", () => { test("thread detail page shows candidates", async ({ page }) => { // Navigate to the active thread await page.goto("/collection?tab=planning"); await page.locator("text=New Backpack").click(); // Should see candidates await expect(page.locator("text=ULA Circuit")).toBeVisible(); await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible(); await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible(); }); test("rank badges are visible on candidates", async ({ page }) => { await page.goto("/collection?tab=planning"); await page.locator("text=New Backpack").click(); // Should see rank badges (gold, silver, bronze for top 3) // The rank badges use specific colors: #D4AF37 (gold), #C0C0C0 (silver), #CD7F32 (bronze) await expect(page.locator("text=#1").first()).toBeVisible(); }); test("comparison view toggles on", async ({ page }) => { await page.goto("/collection?tab=planning"); await page.locator("text=New Backpack").click(); // Find and click the compare toggle const compareButton = page.locator("button", { hasText: /compare/i }); if (await compareButton.isVisible()) { await compareButton.click(); // Comparison table should appear with attribute rows await expect(page.locator("text=Weight")).toBeVisible(); await expect(page.locator("text=Price")).toBeVisible(); } }); test("resolved thread shows winner", async ({ page }) => { await page.goto("/collection?tab=planning"); // Switch to resolved tab await page.locator("button", { hasText: "Resolved" }).click(); await page.locator("text=Camp Stove").click(); // Should indicate resolved state await expect(page.locator("text=BRS-3000T")).toBeVisible(); }); }); ``` - [ ] **Step 2: Create auth tests** Create `e2e/auth.spec.ts`: ```ts import { expect, test } from "@playwright/test"; test.describe("Auth", () => { test("login page renders", async ({ page }) => { await page.goto("/login"); await expect(page.locator("text=Log in")).toBeVisible(); }); test("login with valid credentials succeeds", async ({ page }) => { await page.goto("/login"); await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin"); await page.locator('input[type="password"]').fill("password123"); await page.locator('button[type="submit"]').click(); // Should redirect away from login await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 5000, }); }); test("login with wrong password shows error", async ({ page }) => { await page.goto("/login"); await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin"); await page.locator('input[type="password"]').fill("wrongpassword"); await page.locator('button[type="submit"]').click(); // Should show error message await expect(page.locator("text=Invalid credentials").or(page.locator('[role="alert"]'))).toBeVisible({ timeout: 3000, }); }); }); ``` - [ ] **Step 3: Create error handling tests** Create `e2e/error-handling.spec.ts`: ```ts import { expect, test } from "@playwright/test"; test.describe("Error handling", () => { test("non-existent thread shows not found or error", async ({ page }) => { await page.goto("/threads/99999"); // Should not white-screen — should show some content const body = page.locator("body"); await expect(body).not.toBeEmpty(); // Either shows error boundary or "not found" text const hasContent = await page .locator("text=Something went wrong") .or(page.locator("text=not found")) .or(page.locator("text=Not Found")) .isVisible() .catch(() => false); // At minimum, the page should not be blank const bodyText = await body.textContent(); expect(bodyText?.length).toBeGreaterThan(0); }); test("non-existent setup shows not found or error", async ({ page }) => { await page.goto("/setups/99999"); const body = page.locator("body"); await expect(body).not.toBeEmpty(); const bodyText = await body.textContent(); expect(bodyText?.length).toBeGreaterThan(0); }); test("app recovers from navigation errors", async ({ page }) => { // Navigate to a bad route, then back to a good one await page.goto("/threads/99999"); await page.goto("/"); // Dashboard should load fine await expect(page.locator("text=GearBox")).toBeVisible(); }); }); ``` - [ ] **Step 4: Run all E2E tests** Run: `bun run test:e2e` Expected: All tests pass. - [ ] **Step 5: Commit** ```bash git add e2e/threads.spec.ts e2e/auth.spec.ts e2e/error-handling.spec.ts git commit -m "test: add E2E tests for threads, auth, and error handling" ``` --- ### Task 8: Final Verification - [ ] **Step 1: Run unit tests** Run: `bun test` Expected: All tests pass (previous 183 + new parseId + rate limiter + param routes). - [ ] **Step 2: Run E2E tests** Run: `bun run test:e2e` Expected: All E2E tests pass. - [ ] **Step 3: Run lint** Run: `bun run lint` Expected: Clean.