935 lines
24 KiB
Markdown
935 lines
24 KiB
Markdown
# 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.
|