test: add unit tests for rate limiter middleware
This commit is contained in:
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
@@ -0,0 +1,661 @@
|
||||
# Codebase 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:** Harden the server (explicit DB context, param validation, error handling, rate limiting), add client error boundaries, split the oversized collection route into focused components, and fix stale docs.
|
||||
|
||||
**Architecture:** Server changes are middleware-level (DB context, error handler, rate limiter) plus a small utility for param parsing. Client changes are a TanStack Router error boundary on the root route and extracting three tab components from the 634-line collection route. Docs change is a one-line fix in PROJECT.md.
|
||||
|
||||
**Tech Stack:** Hono middleware, TanStack Router errorComponent, React, TypeScript
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Explicit DB Context Middleware
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/index.ts:1-59`
|
||||
- Modify: `src/server/routes/settings.ts:3,12` (remove prodDb fallback)
|
||||
|
||||
- [ ] **Step 1: Add DB import and middleware to server index**
|
||||
|
||||
In `src/server/index.ts`, add the import for the production database at the top, alongside existing imports:
|
||||
|
||||
```ts
|
||||
import { db as prodDb } from "../db/index.ts";
|
||||
```
|
||||
|
||||
Then add a middleware **before** the auth middleware (before line 26) that sets the DB on every API request:
|
||||
|
||||
```ts
|
||||
// Inject production database into request context
|
||||
app.use("/api/*", async (c, next) => {
|
||||
c.set("db", prodDb);
|
||||
return next();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix auth middleware comment**
|
||||
|
||||
In the same file, update the comment on the auth middleware from:
|
||||
|
||||
```ts
|
||||
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove prodDb fallback from settings route**
|
||||
|
||||
In `src/server/routes/settings.ts`, remove the `prodDb` import and fallback. Change:
|
||||
|
||||
```ts
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
```
|
||||
|
||||
Remove this import entirely.
|
||||
|
||||
Change both occurrences of:
|
||||
```ts
|
||||
const database = c.get("db") ?? prodDb;
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
const database = c.get("db");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass. Tests already set `c.set("db", testDb)` so this change doesn't affect them.
|
||||
|
||||
- [ ] **Step 5: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/index.ts src/server/routes/settings.ts
|
||||
git commit -m "fix: add explicit DB context middleware for all API routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route Parameter Validation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/lib/params.ts`
|
||||
- Modify: `src/server/routes/items.ts`
|
||||
- Modify: `src/server/routes/categories.ts`
|
||||
- Modify: `src/server/routes/threads.ts`
|
||||
- Modify: `src/server/routes/setups.ts`
|
||||
- Modify: `src/server/routes/auth.ts:187-189`
|
||||
|
||||
- [ ] **Step 1: Create parseId helper**
|
||||
|
||||
Create `src/server/lib/params.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Parse a route parameter as a positive integer ID.
|
||||
* Returns the number if valid, or null if the string is not a positive integer.
|
||||
*/
|
||||
export function parseId(raw: string): number | null {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return null;
|
||||
return id;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update items routes**
|
||||
|
||||
In `src/server/routes/items.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace all `Number(c.req.param("id"))` patterns. For each route that uses an ID param, add validation. Example for `GET /:id`:
|
||||
|
||||
```ts
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const item = getItemById(db, id);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(item);
|
||||
});
|
||||
```
|
||||
|
||||
Apply the same pattern to `PUT /:id` and `DELETE /:id`. In each case, add `const id = parseId(...)` + the null check returning 400 right after.
|
||||
|
||||
- [ ] **Step 3: Update categories routes**
|
||||
|
||||
In `src/server/routes/categories.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace `Number(c.req.param("id"))` with `parseId(c.req.param("id"))` in `PUT /:id` and `DELETE /:id`, adding the null check:
|
||||
|
||||
```ts
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update threads routes**
|
||||
|
||||
In `src/server/routes/threads.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace all `Number(c.req.param(...))` calls. There are 8 occurrences across these handlers:
|
||||
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||
- `PUT /:id` — same
|
||||
- `DELETE /:id` — same
|
||||
- `POST /:id/candidates` — `const threadId = parseId(c.req.param("id"))`
|
||||
- `PUT /:threadId/candidates/:candidateId` — `const candidateId = parseId(c.req.param("candidateId"))`
|
||||
- `DELETE /:threadId/candidates/:candidateId` — same
|
||||
- `PATCH /:id/candidates/reorder` — `const threadId = parseId(c.req.param("id"))`
|
||||
- `POST /:id/resolve` — `const threadId = parseId(c.req.param("id"))`
|
||||
|
||||
For each, add the null check returning 400 with a descriptive message like `"Invalid thread ID"` or `"Invalid candidate ID"`.
|
||||
|
||||
- [ ] **Step 5: Update setups routes**
|
||||
|
||||
In `src/server/routes/setups.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace all `Number(c.req.param(...))` calls. There are 6 occurrences:
|
||||
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||
- `PUT /:id` — same
|
||||
- `DELETE /:id` — same
|
||||
- `PUT /:id/items` — same
|
||||
- `PATCH /:id/items/:itemId/classification` — both `setupId` and `itemId`
|
||||
- `DELETE /:id/items/:itemId` — both `setupId` and `itemId`
|
||||
|
||||
For the classification and item removal routes with two params:
|
||||
|
||||
```ts
|
||||
const setupId = parseId(c.req.param("id"));
|
||||
const itemId = parseId(c.req.param("itemId"));
|
||||
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update auth routes**
|
||||
|
||||
In `src/server/routes/auth.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Update `DELETE /keys/:id` (line 187-189):
|
||||
|
||||
```ts
|
||||
app.delete("/keys/:id", requireAuth, (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid key ID" }, 400);
|
||||
deleteApiKey(db, id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass. Existing tests use valid integer IDs so no breakage.
|
||||
|
||||
- [ ] **Step 8: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/lib/params.ts src/server/routes/items.ts src/server/routes/categories.ts src/server/routes/threads.ts src/server/routes/setups.ts src/server/routes/auth.ts
|
||||
git commit -m "fix: validate route ID parameters, return 400 for invalid IDs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Centralized Error Handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/index.ts`
|
||||
|
||||
- [ ] **Step 1: Add onError handler**
|
||||
|
||||
In `src/server/index.ts`, add the error handler after the app is created (after `const app = new Hono()`) but before any routes:
|
||||
|
||||
```ts
|
||||
// Centralized error handler
|
||||
app.onError((err, c) => {
|
||||
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||
const message =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "Internal server error"
|
||||
: err.message || "Internal server error";
|
||||
return c.json({ error: message }, 500);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/index.ts
|
||||
git commit -m "fix: add centralized error handler for unhandled exceptions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rate Limiting on Auth Endpoints
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/middleware/rateLimit.ts`
|
||||
- Modify: `src/server/routes/auth.ts`
|
||||
|
||||
- [ ] **Step 1: Create rate limiter middleware**
|
||||
|
||||
Create `src/server/middleware/rateLimit.ts`:
|
||||
|
||||
```ts
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
function getClientIp(c: Context): string {
|
||||
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now >= entry.resetAt) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function rateLimit(c: Context, next: Next) {
|
||||
cleanup();
|
||||
|
||||
const ip = getClientIp(c);
|
||||
const key = `${ip}:${c.req.path}`;
|
||||
const now = Date.now();
|
||||
|
||||
const entry = store.get(key);
|
||||
|
||||
if (!entry || now >= entry.resetAt) {
|
||||
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return next();
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_ATTEMPTS) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return next();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apply rate limiter to auth routes**
|
||||
|
||||
In `src/server/routes/auth.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { rateLimit } from "../middleware/rateLimit.ts";
|
||||
```
|
||||
|
||||
Update the `POST /setup` handler to include the rate limiter:
|
||||
|
||||
```ts
|
||||
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
|
||||
```
|
||||
|
||||
Update the `POST /login` handler to include the rate limiter:
|
||||
|
||||
```ts
|
||||
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass. Auth tests make fewer than 5 requests per endpoint so rate limiting won't trigger.
|
||||
|
||||
- [ ] **Step 4: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/middleware/rateLimit.ts src/server/routes/auth.ts
|
||||
git commit -m "feat: add rate limiting on login and setup endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Client Error Boundary
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/client/routes/__root.tsx`
|
||||
|
||||
- [ ] **Step 1: Add error boundary component and wire it up**
|
||||
|
||||
In `src/client/routes/__root.tsx`, add the import for `useRouter` at the top (add to existing import from `@tanstack/react-router`):
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRootRoute,
|
||||
Outlet,
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
type ErrorComponentProps,
|
||||
} from "@tanstack/react-router";
|
||||
```
|
||||
|
||||
Add the `errorComponent` to the route definition:
|
||||
|
||||
```ts
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
errorComponent: RootErrorBoundary,
|
||||
});
|
||||
```
|
||||
|
||||
Add the `RootErrorBoundary` function before `RootLayout`:
|
||||
|
||||
```tsx
|
||||
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto text-center px-4">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-6 h-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset();
|
||||
router.invalidate();
|
||||
}}
|
||||
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/routes/__root.tsx
|
||||
git commit -m "feat: add error boundary to root route for crash resilience"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Split Collection Route into Tab Components
|
||||
|
||||
**Files:**
|
||||
- Create: `src/client/components/CollectionView.tsx`
|
||||
- Create: `src/client/components/PlanningView.tsx`
|
||||
- Create: `src/client/components/SetupsView.tsx`
|
||||
- Modify: `src/client/routes/collection/index.tsx`
|
||||
|
||||
- [ ] **Step 1: Create CollectionView component**
|
||||
|
||||
Create `src/client/components/CollectionView.tsx` with the `CollectionView` function extracted from `collection/index.tsx` (lines 72-334). The component needs these imports:
|
||||
|
||||
```tsx
|
||||
import { useMemo, useState } from "react";
|
||||
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||
import { CategoryHeader } from "./CategoryHeader";
|
||||
import { ItemCard } from "./ItemCard";
|
||||
import { useCategories } from "../hooks/useCategories";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useItems } from "../hooks/useItems";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export function CollectionView() {
|
||||
// ... exact same function body as lines 73-334 of collection/index.tsx
|
||||
}
|
||||
```
|
||||
|
||||
Copy the entire `CollectionView` function body as-is. No logic changes.
|
||||
|
||||
- [ ] **Step 2: Create PlanningView component**
|
||||
|
||||
Create `src/client/components/PlanningView.tsx` with the `PlanningView` function extracted from `collection/index.tsx` (lines 337-523):
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||
import { CreateThreadModal } from "./CreateThreadModal";
|
||||
import { ThreadCard } from "./ThreadCard";
|
||||
import { useCategories } from "../hooks/useCategories";
|
||||
import { useThreads } from "../hooks/useThreads";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export function PlanningView() {
|
||||
// ... exact same function body as lines 338-523 of collection/index.tsx
|
||||
}
|
||||
```
|
||||
|
||||
Copy the entire `PlanningView` function body as-is. No logic changes.
|
||||
|
||||
- [ ] **Step 3: Create SetupsView component**
|
||||
|
||||
Create `src/client/components/SetupsView.tsx` with the `SetupsView` function extracted from `collection/index.tsx` (lines 526-633):
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { SetupCard } from "./SetupCard";
|
||||
import { useCreateSetup, useSetups } from "../hooks/useSetups";
|
||||
|
||||
export function SetupsView() {
|
||||
// ... exact same function body as lines 527-633 of collection/index.tsx
|
||||
}
|
||||
```
|
||||
|
||||
Copy the entire `SetupsView` function body as-is. No logic changes.
|
||||
|
||||
- [ ] **Step 4: Update collection/index.tsx**
|
||||
|
||||
Replace the entire file content. Keep only the route definition, tab switching logic, animation constants, and imports from the new components:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { z } from "zod";
|
||||
import { CollectionView } from "../../components/CollectionView";
|
||||
import { PlanningView } from "../../components/PlanningView";
|
||||
import { SetupsView } from "../../components/SetupsView";
|
||||
|
||||
const searchSchema = z.object({
|
||||
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/collection/")({
|
||||
validateSearch: searchSchema,
|
||||
component: CollectionPage,
|
||||
});
|
||||
|
||||
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
||||
|
||||
const slideVariants = {
|
||||
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }),
|
||||
};
|
||||
|
||||
function CollectionPage() {
|
||||
const { tab } = Route.useSearch();
|
||||
const prevTab = useRef(tab);
|
||||
|
||||
const direction =
|
||||
TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
|
||||
prevTab.current = tab;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
||||
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||
<motion.div
|
||||
key={tab}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.12, ease: "easeInOut" }}
|
||||
>
|
||||
{tab === "gear" ? (
|
||||
<CollectionView />
|
||||
) : tab === "planning" ? (
|
||||
<PlanningView />
|
||||
) : (
|
||||
<SetupsView />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors. (Biome may flag import organization — fix if needed.)
|
||||
|
||||
- [ ] **Step 6: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/components/CollectionView.tsx src/client/components/PlanningView.tsx src/client/components/SetupsView.tsx src/client/routes/collection/index.tsx
|
||||
git commit -m "refactor: extract tab views from collection route into separate components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `.planning/PROJECT.md:84`
|
||||
|
||||
- [ ] **Step 1: Update stale constraint**
|
||||
|
||||
In `.planning/PROJECT.md`, change line 84 from:
|
||||
|
||||
```
|
||||
- **Scope**: No auth, single user for v1
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```
|
||||
- **Scope**: Single user with cookie/API key auth
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .planning/PROJECT.md
|
||||
git commit -m "docs: update PROJECT.md constraints to reflect auth implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final Verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Verify dev server starts**
|
||||
|
||||
Run: `bun run dev:server &` then `curl http://localhost:3000/api/health`
|
||||
Expected: `{"status":"ok"}`
|
||||
Then kill the background server.
|
||||
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
@@ -0,0 +1,934 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Code Quality Improvements (Round 2) Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Scope:** Combined formatters hook, test helper schema generation, stale todo cleanup
|
||||
|
||||
## 1. useFormatters Combined Hook
|
||||
|
||||
**Problem:** 14 component files import the same 3-4 lines: `useWeightUnit`, `useCurrency`, `formatWeight`, `formatPrice`. This is repetitive boilerplate.
|
||||
|
||||
**Solution:** Create `src/client/hooks/useFormatters.ts` that returns pre-bound formatting functions:
|
||||
|
||||
```ts
|
||||
export function useFormatters() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
return {
|
||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||
price: (cents: number | null) => formatPrice(cents, currency),
|
||||
unit,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Consumer files to update (14):**
|
||||
- CollectionView.tsx
|
||||
- setups/$setupId.tsx
|
||||
- routes/index.tsx
|
||||
- WeightSummaryCard.tsx
|
||||
- TotalsBar.tsx
|
||||
- settings.tsx
|
||||
- ThreadCard.tsx
|
||||
- SetupCard.tsx
|
||||
- ItemPicker.tsx
|
||||
- ItemCard.tsx
|
||||
- ComparisonTable.tsx
|
||||
- CandidateCard.tsx
|
||||
- CandidateListItem.tsx
|
||||
- CategoryHeader.tsx
|
||||
|
||||
Each file replaces 3-4 imports + 2 hook calls with 1 import + 1 destructured hook call. Components that need raw `unit` or `currency` (e.g., WeightSummaryCard uses `unit` as a type, TotalsBar has a unit toggle) get them from the return object.
|
||||
|
||||
## 2. Test Helper Schema Generation
|
||||
|
||||
**Problem:** `tests/helpers/db.ts` has 120 lines of hand-written CREATE TABLE SQL that must manually mirror `src/db/schema.ts`. Any schema change requires updating both files — a known source of `SqliteError: no such column` failures.
|
||||
|
||||
**Solution:** Replace hand-written SQL with Drizzle's migration runner:
|
||||
|
||||
```ts
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
|
||||
export function createTestDb() {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.run("PRAGMA foreign_keys = ON");
|
||||
const db = drizzle(sqlite, { schema });
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
db.insert(schema.categories).values({ name: "Uncategorized", icon: "package" }).run();
|
||||
return db;
|
||||
}
|
||||
```
|
||||
|
||||
This reduces the file from ~128 lines to ~15 lines and eliminates all future manual sync.
|
||||
|
||||
## 3. Stale Todo Cleanup
|
||||
|
||||
**Problem:** Pending todo "Replace planning category filter select with icon-aware dropdown" from 2026-03-15 is already resolved — `PlanningView.tsx` uses `<CategoryFilterDropdown>` which renders Lucide icons.
|
||||
|
||||
**Solution:** Move the todo file from `pending/` to `done/`.
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. **useFormatters hook** — create hook + update all 14 consumer files
|
||||
2. **Test helper migration** — replace hand-written SQL with migrate()
|
||||
3. **Todo cleanup** — move stale todo to done
|
||||
@@ -0,0 +1,152 @@
|
||||
# Codebase Improvements Design
|
||||
|
||||
**Date:** 2026-04--03
|
||||
**Scope:** General code quality, error handling, resilience, and maintainability improvements
|
||||
|
||||
## 1. Server Hardening
|
||||
|
||||
### 1a. Explicit DB Context Middleware
|
||||
|
||||
**File:** `src/server/index.ts`
|
||||
|
||||
Add middleware that explicitly sets `c.set("db", prodDb)` for all API routes. Currently routes call `c.get("db")` but nothing sets it in production — services silently fall back to `prodDb` via default parameters. This makes production behavior match the test pattern.
|
||||
|
||||
```ts
|
||||
import { db as prodDb } from "../db/index.ts";
|
||||
|
||||
app.use("/api/*", async (c, next) => {
|
||||
c.set("db", prodDb);
|
||||
return next();
|
||||
});
|
||||
```
|
||||
|
||||
Place this **before** the auth middleware so `db` is available when auth checks run.
|
||||
|
||||
### 1b. Route Parameter Validation
|
||||
|
||||
**New file:** `src/server/lib/params.ts`
|
||||
|
||||
Create a helper that validates numeric route params:
|
||||
|
||||
```ts
|
||||
export function parseId(raw: string): number | null {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return null;
|
||||
return id;
|
||||
}
|
||||
```
|
||||
|
||||
Update all route files (`items.ts`, `threads.ts`, `categories.ts`, `setups.ts`) to replace `Number(c.req.param("id"))` with `parseId()`, returning 400 for invalid IDs.
|
||||
|
||||
### 1c. Centralized Error Handling
|
||||
|
||||
**File:** `src/server/index.ts`
|
||||
|
||||
Add Hono's `onError` handler:
|
||||
|
||||
```ts
|
||||
app.onError((err, c) => {
|
||||
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||
const status = err instanceof HTTPException ? err.status : 500;
|
||||
const message = process.env.NODE_ENV === "production"
|
||||
? "Internal server error"
|
||||
: err.message;
|
||||
return c.json({ error: message }, status);
|
||||
});
|
||||
```
|
||||
|
||||
### 1d. Auth Comment Fix
|
||||
|
||||
**File:** `src/server/index.ts`
|
||||
|
||||
Change comment from:
|
||||
```
|
||||
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||
```
|
||||
To:
|
||||
```
|
||||
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||
```
|
||||
|
||||
### 1e. Rate Limiting on Auth Endpoints
|
||||
|
||||
**New file:** `src/server/middleware/rateLimit.ts`
|
||||
|
||||
In-memory rate limiter using a `Map<string, { count: number; resetAt: number }>`:
|
||||
|
||||
- Tracks by IP (`c.req.header("x-forwarded-for") || "unknown"`)
|
||||
- 5 attempts per 15-minute window
|
||||
- Returns 429 with `{ error: "Too many attempts. Try again later." }` and `Retry-After` header
|
||||
- Stale entries cleaned on each check
|
||||
- Applied to `POST /api/auth/login` and `POST /api/auth/setup`
|
||||
|
||||
## 2. Client Resilience
|
||||
|
||||
### Error Boundary
|
||||
|
||||
**File:** `src/client/routes/__root.tsx`
|
||||
|
||||
Add `errorComponent` to the root route definition:
|
||||
|
||||
```ts
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
errorComponent: RootErrorBoundary,
|
||||
});
|
||||
```
|
||||
|
||||
`RootErrorBoundary` renders a centered error message with:
|
||||
- "Something went wrong" heading
|
||||
- Error message in dev mode
|
||||
- "Try again" button that calls `router.invalidate()` + `reset()`
|
||||
|
||||
Uses TanStack Router's `ErrorComponentProps` which provides `error` and `reset`.
|
||||
|
||||
## 3. Client Refactor
|
||||
|
||||
### Split collection/index.tsx
|
||||
|
||||
Extract the three tab-level functions into separate component files:
|
||||
|
||||
| Source function | New file | Approx lines |
|
||||
|----------------|----------|-------------|
|
||||
| `CollectionView()` | `src/client/components/CollectionView.tsx` | ~260 |
|
||||
| `PlanningView()` | `src/client/components/PlanningView.tsx` | ~190 |
|
||||
| `SetupsView()` | `src/client/components/SetupsView.tsx` | ~110 |
|
||||
|
||||
`collection/index.tsx` keeps:
|
||||
- Route definition with `searchSchema` and `validateSearch`
|
||||
- `CollectionPage` function (tab switcher + AnimatePresence)
|
||||
- `TAB_ORDER` and `slideVariants` constants
|
||||
- Imports from the three new component files
|
||||
|
||||
Each extracted component is a named export, self-contained with its own hooks and local state.
|
||||
|
||||
## 4. Docs Cleanup
|
||||
|
||||
### PROJECT.md
|
||||
|
||||
**File:** `.planning/PROJECT.md`
|
||||
|
||||
Update Constraints section line:
|
||||
```
|
||||
- **Scope**: No auth, single user for v1
|
||||
```
|
||||
To:
|
||||
```
|
||||
- **Scope**: Single user with cookie/API key auth
|
||||
```
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
Group into 3-4 commits by area:
|
||||
1. **Server hardening**: DB middleware, param validation, error handler, rate limiter, comment fix
|
||||
2. **Client resilience + refactor**: Error boundary, split collection route
|
||||
3. **Docs cleanup**: PROJECT.md update
|
||||
|
||||
## Testing
|
||||
|
||||
- All 183 existing tests must continue to pass
|
||||
- Rate limiter: manual verification (no automated test needed for in-memory rate limiting in a single-user app)
|
||||
- Error boundary: manual verification by triggering a render error
|
||||
- Param validation: existing route tests cover happy paths; invalid IDs are a new edge case but won't break existing tests
|
||||
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Testing Improvements Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Scope:** Unit tests for new server code + Playwright E2E test setup with seeded database
|
||||
|
||||
## Part 1: Unit/Integration Tests (Bun test runner)
|
||||
|
||||
### tests/lib/params.test.ts
|
||||
|
||||
Tests for `parseId` helper in `src/server/lib/params.ts`:
|
||||
- Valid positive integers (1, 42, 999) return the number
|
||||
- Zero returns null
|
||||
- Negative numbers (-1, -100) return null
|
||||
- Decimals (1.5, 3.14) return null
|
||||
- Non-numeric strings ("abc", "", "hello") return null
|
||||
- NaN-producing values return null
|
||||
|
||||
### tests/middleware/rateLimit.test.ts
|
||||
|
||||
Tests for rate limiter in `src/server/middleware/rateLimit.ts`:
|
||||
- First request passes through (200)
|
||||
- 5 requests succeed, 6th returns 429
|
||||
- 429 response includes `Retry-After` header
|
||||
- Different IPs tracked independently
|
||||
- After window expires, requests succeed again
|
||||
|
||||
Since the rate limiter uses a module-level `Map`, tests need to either:
|
||||
- Reset the store between tests (export a `resetStore` for testing), OR
|
||||
- Use unique paths/IPs per test to avoid interference
|
||||
|
||||
Recommended: export a `_resetForTesting()` function from rateLimit.ts that clears the store. Only used in tests.
|
||||
|
||||
### tests/routes/params.test.ts
|
||||
|
||||
Route-level integration tests verifying 400 responses for invalid IDs:
|
||||
- `GET /api/items/abc` → 400
|
||||
- `GET /api/items/-1` → 400
|
||||
- `GET /api/items/0` → 400
|
||||
- `DELETE /api/categories/notanumber` → 400
|
||||
- `GET /api/threads/abc` → 400
|
||||
- `GET /api/setups/abc` → 400
|
||||
|
||||
Uses existing test app pattern with in-memory DB.
|
||||
|
||||
## Part 2: Playwright E2E Setup
|
||||
|
||||
### Installation
|
||||
|
||||
- `bun add -d @playwright/test`
|
||||
- `bunx playwright install chromium` (only Chromium needed)
|
||||
|
||||
### Configuration: playwright.config.ts
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
webServer: {
|
||||
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
```
|
||||
|
||||
### Database Seeding: e2e/seed.ts
|
||||
|
||||
Script that creates `e2e/test.db` with:
|
||||
- Run Drizzle migrations against the file
|
||||
- Seed data:
|
||||
- 1 user (username: "admin", password: "password123")
|
||||
- 3 categories: Shelter, Sleep System, Cook Kit
|
||||
- 6 items across categories with realistic weights/prices
|
||||
- 1 active thread with 3 candidates (with pros/cons, sort_order)
|
||||
- 1 resolved thread
|
||||
- 1 setup with 4 items (mixed classifications)
|
||||
- Settings: weightUnit=g, currency=USD, onboardingComplete=true
|
||||
|
||||
Run before E2E tests via `e2e/global-setup.ts` (Playwright globalSetup).
|
||||
|
||||
### E2E Test Files
|
||||
|
||||
**e2e/dashboard.spec.ts**
|
||||
- Dashboard page loads
|
||||
- Summary cards show item count, weight, cost
|
||||
- Navigation links to collection work
|
||||
|
||||
**e2e/collection.spec.ts**
|
||||
- Gear tab renders items grouped by category
|
||||
- Search input filters items by name
|
||||
- Category filter dropdown works
|
||||
- Tab switching between gear/planning/setups
|
||||
|
||||
**e2e/threads.spec.ts**
|
||||
- Thread detail page loads with candidates
|
||||
- Comparison view toggle works (shows table)
|
||||
- Rank badges visible on candidates
|
||||
|
||||
**e2e/auth.spec.ts**
|
||||
- Login page renders
|
||||
- Login with valid credentials succeeds
|
||||
- Login with wrong password shows error
|
||||
- Rate limiting returns error after 5 attempts
|
||||
|
||||
**e2e/error-boundary.spec.ts**
|
||||
- App doesn't white-screen on unknown routes
|
||||
- Navigating to a non-existent thread/setup shows appropriate error
|
||||
|
||||
### Scripts
|
||||
|
||||
Add to package.json:
|
||||
- `"test:e2e": "bunx playwright test"`
|
||||
- `"test:e2e:ui": "bunx playwright test --ui"` (for debugging)
|
||||
|
||||
### Files to .gitignore
|
||||
|
||||
- `e2e/test.db`
|
||||
- `test-results/`
|
||||
- `playwright-report/`
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. Unit tests for parseId, rate limiter, route params
|
||||
2. Playwright setup (install, config, seed, global-setup)
|
||||
3. Playwright E2E test files
|
||||
@@ -46,3 +46,8 @@ export async function rateLimit(c: Context, next: Next) {
|
||||
entry.count++;
|
||||
return next();
|
||||
}
|
||||
|
||||
/** @internal — only for testing */
|
||||
export function _resetForTesting() {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
82
tests/middleware/rateLimit.test.ts
Normal file
82
tests/middleware/rateLimit.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login", "10.0.0.1");
|
||||
}
|
||||
const blocked = await makeRequest(app, "/login", "10.0.0.1");
|
||||
expect(blocked.status).toBe(429);
|
||||
const allowed = await makeRequest(app, "/login", "10.0.0.2");
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("tracks different paths independently", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login");
|
||||
}
|
||||
const blockedLogin = await makeRequest(app, "/login");
|
||||
expect(blockedLogin.status).toBe(429);
|
||||
const allowedSetup = await makeRequest(app, "/setup");
|
||||
expect(allowedSetup.status).toBe(200);
|
||||
});
|
||||
});
|
||||
79
tests/routes/params.test.ts
Normal file
79
tests/routes/params.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user