Files
GearBox/docs/superpowers/plans/2026-04-03-testing-improvements.md

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.