24 KiB
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:
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
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:
/** @internal — only for testing */
export function _resetForTesting() {
store.clear();
}
- Step 2: Write tests
Create tests/middleware/rateLimit.test.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
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:
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
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
bun add -d @playwright/test
bunx playwright install chromium
- Step 2: Create playwright.config.ts
Create playwright.config.ts at project root:
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:
"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
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:
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:
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
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:
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:
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
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:
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:
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:
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
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.