diff --git a/src/server/routes/admin-tags.ts b/src/server/routes/admin-tags.ts new file mode 100644 index 0000000..4683df4 --- /dev/null +++ b/src/server/routes/admin-tags.ts @@ -0,0 +1,80 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import { parseId } from "../lib/params.ts"; +import { + createTag, + deleteTag, + getAdminTags, + getTagWithCounts, + updateTag, +} from "../services/tag.service.ts"; + +type Env = { Variables: { db?: any; userId?: number } }; + +const app = new Hono(); + +const createTagSchema = z.object({ + name: z.string().min(1), + parentId: z.number().int().positive().nullable().optional(), +}); + +const updateTagSchema = z.object({ + name: z.string().min(1).optional(), + parentId: z.number().int().positive().nullable().optional(), +}); + +// GET /api/admin/tags — list all tags with parentId and itemCount +app.get("/", async (c) => { + const db = c.get("db"); + const result = await getAdminTags(db); + return c.json(result); +}); + +// GET /api/admin/tags/:id — single tag with counts +app.get("/:id", async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid tag ID" }, 400); + const tag = await getTagWithCounts(db, id); + if (!tag) return c.json({ error: "Tag not found" }, 404); + return c.json(tag); +}); + +// POST /api/admin/tags — create a new tag +app.post("/", zValidator("json", createTagSchema), async (c) => { + const db = c.get("db"); + const data = c.req.valid("json"); + const tag = await createTag(db, data); + return c.json(tag, 201); +}); + +// PUT /api/admin/tags/:id — rename and/or reparent a tag +app.put("/:id", zValidator("json", updateTagSchema), async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid tag ID" }, 400); + const data = c.req.valid("json"); + try { + const tag = await updateTag(db, id, data); + if (!tag) return c.json({ error: "Tag not found" }, 404); + return c.json(tag); + } catch (err) { + if (err instanceof Error && err.message.startsWith("Cycle detected")) { + return c.json({ error: err.message }, 400); + } + throw err; + } +}); + +// DELETE /api/admin/tags/:id — remove tag (children become top-level via ON DELETE SET NULL) +app.delete("/:id", async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid tag ID" }, 400); + const deleted = await deleteTag(db, id); + if (!deleted) return c.json({ error: "Tag not found" }, 404); + return c.json({ success: true }); +}); + +export { app as adminTagRoutes }; diff --git a/src/server/routes/admin.ts b/src/server/routes/admin.ts index ef1986e..21cecfc 100644 --- a/src/server/routes/admin.ts +++ b/src/server/routes/admin.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { requireAdmin, requireAuth } from "../middleware/auth.ts"; import { adminItemRoutes } from "./admin-items.ts"; +import { adminTagRoutes } from "./admin-tags.ts"; type Env = { Variables: { db?: any; userId?: number } }; @@ -17,4 +18,7 @@ app.get("/", async (c) => { // Admin item management app.route("/items", adminItemRoutes); +// Admin tag management +app.route("/tags", adminTagRoutes); + export { app as adminRoutes }; diff --git a/tests/routes/admin-tags.test.ts b/tests/routes/admin-tags.test.ts new file mode 100644 index 0000000..9869572 --- /dev/null +++ b/tests/routes/admin-tags.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { tags } from "../../src/db/schema.ts"; +import { adminTagRoutes } from "../../src/server/routes/admin-tags.ts"; +import { createTestDb } from "../helpers/db.ts"; + +function createTestApp(db: any) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + app.route("/api/admin/tags", adminTagRoutes); + return app; +} + +async function insertTag(db: any, name: string, parentId?: number | null) { + const [row] = await db + .insert(tags) + .values({ name, parentId: parentId ?? null }) + .returning(); + return row!; +} + +describe("Admin Tag Routes", () => { + let app: Hono; + let db: Awaited>["db"]; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + app = createTestApp(db); + }); + + describe("GET /api/admin/tags", () => { + it("returns 200 with empty array when no tags", async () => { + const res = await app.request("/api/admin/tags"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); + + it("returns tags with id, name, parentId, itemCount fields after seeding", async () => { + await insertTag(db, "bikepacking"); + const res = await app.request("/api/admin/tags"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ + id: expect.any(Number), + name: "bikepacking", + parentId: null, + itemCount: expect.any(Number), + }); + }); + }); + + describe("POST /api/admin/tags", () => { + it("returns 201 with created tag", async () => { + const res = await app.request("/api/admin/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "ultralight" }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ id: expect.any(Number), name: "ultralight", parentId: null }); + }); + + it("creates tag with parentId", async () => { + const parent = await insertTag(db, "gear"); + const res = await app.request("/api/admin/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "clothing", parentId: parent.id }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.parentId).toBe(parent.id); + }); + + it("returns 400 for empty name", async () => { + const res = await app.request("/api/admin/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + expect(res.status).toBe(400); + }); + }); + + describe("PUT /api/admin/tags/:id", () => { + it("renames a tag", async () => { + const tag = await insertTag(db, "old-name"); + const res = await app.request(`/api/admin/tags/${tag.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "new-name" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("new-name"); + }); + + it("updates parentId", async () => { + const parent = await insertTag(db, "parent"); + const child = await insertTag(db, "child"); + const res = await app.request(`/api/admin/tags/${child.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId: parent.id }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.parentId).toBe(parent.id); + }); + + it("sets parentId to null (reparent to top-level)", async () => { + const parent = await insertTag(db, "parent"); + const child = await insertTag(db, "child", parent.id); + const res = await app.request(`/api/admin/tags/${child.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId: null }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.parentId).toBeNull(); + }); + + it("returns 400 for cycle (A->B->C, try to set A as child of C)", async () => { + const a = await insertTag(db, "A"); + const b = await insertTag(db, "B", a.id); + const c = await insertTag(db, "C", b.id); + const res = await app.request(`/api/admin/tags/${a.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId: c.id }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Cycle detected"); + }); + + it("returns 404 for non-existent id", async () => { + const res = await app.request("/api/admin/tags/99999", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "ghost" }), + }); + expect(res.status).toBe(404); + }); + }); + + describe("DELETE /api/admin/tags/:id", () => { + it("returns 200 with { success: true }", async () => { + const tag = await insertTag(db, "to-delete"); + const res = await app.request(`/api/admin/tags/${tag.id}`, { + method: "DELETE", + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ success: true }); + }); + + it("children become orphans (parentId null) after parent deletion", async () => { + const parent = await insertTag(db, "parent"); + const child = await insertTag(db, "child", parent.id); + await app.request(`/api/admin/tags/${parent.id}`, { method: "DELETE" }); + const res = await app.request("/api/admin/tags"); + const body = await res.json(); + const childRow = body.find((t: any) => t.id === child.id); + expect(childRow?.parentId).toBeNull(); + }); + + it("returns 404 for non-existent id", async () => { + const res = await app.request("/api/admin/tags/99999", { + method: "DELETE", + }); + expect(res.status).toBe(404); + }); + }); +});