feat(01-02): add Hono API routes with validation, image upload, and integration tests

- Item routes: GET, POST, PUT, DELETE with Zod validation and image cleanup
- Category routes: GET, POST, PUT, DELETE with Uncategorized protection
- Totals route: per-category and global aggregates
- Image upload: multipart file handling with type/size validation
- Routes use DI via Hono context variables for testability
- Integration tests: 10 tests covering all endpoints and edge cases
- All 30 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 22:40:49 +01:00
parent 22757a8aef
commit 029adf4dca
7 changed files with 412 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { Hono } from "hono";
import { createTestDb } from "../helpers/db.ts";
import { categoryRoutes } from "../../src/server/routes/categories.ts";
import { itemRoutes } from "../../src/server/routes/items.ts";
function createTestApp() {
const db = createTestDb();
const app = new Hono();
// Inject test DB into context for all routes
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/categories", categoryRoutes);
app.route("/api/items", itemRoutes);
return { app, db };
}
describe("Category Routes", () => {
let app: Hono;
beforeEach(() => {
const testApp = createTestApp();
app = testApp.app;
});
it("POST /api/categories creates category", async () => {
const res = await app.request("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Shelter", emoji: "\u{26FA}" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Shelter");
expect(body.emoji).toBe("\u{26FA}");
expect(body.id).toBeGreaterThan(0);
});
it("GET /api/categories returns all categories", async () => {
const res = await app.request("/api/categories");
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
// At minimum, Uncategorized is seeded
expect(body.length).toBeGreaterThanOrEqual(1);
});
it("DELETE /api/categories/:id reassigns items", async () => {
// Create category
const catRes = await app.request("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Shelter", emoji: "\u{26FA}" }),
});
const cat = await catRes.json();
// Create item in that category
await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tent", categoryId: cat.id }),
});
// Delete the category
const delRes = await app.request(`/api/categories/${cat.id}`, {
method: "DELETE",
});
expect(delRes.status).toBe(200);
// Verify items are now in Uncategorized
const itemsRes = await app.request("/api/items");
const items = await itemsRes.json();
const tent = items.find((i: any) => i.name === "Tent");
expect(tent.categoryId).toBe(1);
});
it("DELETE /api/categories/1 returns 400 (cannot delete Uncategorized)", async () => {
const res = await app.request("/api/categories/1", {
method: "DELETE",
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("Uncategorized");
});
});