diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 17:37:08 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 17:37:08 +0900 |
| commit | 797ef2fcfaa7ac63355c13809a644401a76250bc (patch) | |
| tree | 70814083131572b31b72fdbaeea74b2d7aa60d91 | |
| parent | 943674471d062ea4494727ce308c8c429afd6f98 (diff) | |
| download | kioku-797ef2fcfaa7ac63355c13809a644401a76250bc.tar.gz kioku-797ef2fcfaa7ac63355c13809a644401a76250bc.tar.zst kioku-797ef2fcfaa7ac63355c13809a644401a76250bc.zip | |
feat(server): add Deck CRUD endpoints with tests
Implement complete Deck management API:
- GET /api/decks - List user's decks
- POST /api/decks - Create new deck
- GET /api/decks/:id - Get deck by ID
- PUT /api/decks/:id - Update deck
- DELETE /api/decks/:id - Soft delete deck
All endpoints require authentication and scope data to the
authenticated user. Includes 22 unit tests covering success
and error cases.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 4 | ||||
| -rw-r--r-- | src/server/index.ts | 5 | ||||
| -rw-r--r-- | src/server/repositories/deck.ts | 95 | ||||
| -rw-r--r-- | src/server/repositories/index.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 33 | ||||
| -rw-r--r-- | src/server/routes/decks.test.ts | 480 | ||||
| -rw-r--r-- | src/server/routes/decks.ts | 82 | ||||
| -rw-r--r-- | src/server/routes/index.ts | 1 |
8 files changed, 697 insertions, 4 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 9b39fc7..819107e 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -80,8 +80,8 @@ Smaller features first to enable early MVP validation. **Goal**: Create, edit, and delete decks ### Server API -- [ ] Deck CRUD endpoints (GET, POST, PUT, DELETE) -- [ ] Add tests +- [x] Deck CRUD endpoints (GET, POST, PUT, DELETE) +- [x] Add tests ### Frontend - [ ] Deck list page (empty state, list view) diff --git a/src/server/index.ts b/src/server/index.ts index d157f74..bcedb4e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,7 +2,7 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { logger } from "hono/logger"; import { errorHandler } from "./middleware/index.js"; -import { auth } from "./routes/index.js"; +import { auth, decks } from "./routes/index.js"; const app = new Hono(); @@ -17,7 +17,8 @@ const routes = app .get("/api/health", (c) => { return c.json({ status: "ok" }, 200); }) - .route("/api/auth", auth); + .route("/api/auth", auth) + .route("/api/decks", decks); export type AppType = typeof routes; diff --git a/src/server/repositories/deck.ts b/src/server/repositories/deck.ts new file mode 100644 index 0000000..77985a7 --- /dev/null +++ b/src/server/repositories/deck.ts @@ -0,0 +1,95 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { decks } from "../db/schema.js"; +import type { Deck, DeckRepository } from "./types.js"; + +export const deckRepository: DeckRepository = { + async findByUserId(userId: string): Promise<Deck[]> { + const result = await db + .select() + .from(decks) + .where(and(eq(decks.userId, userId), isNull(decks.deletedAt))); + return result; + }, + + async findById(id: string, userId: string): Promise<Deck | undefined> { + const result = await db + .select() + .from(decks) + .where( + and( + eq(decks.id, id), + eq(decks.userId, userId), + isNull(decks.deletedAt), + ), + ); + return result[0]; + }, + + async create(data: { + userId: string; + name: string; + description?: string | null; + newCardsPerDay?: number; + }): Promise<Deck> { + const [deck] = await db + .insert(decks) + .values({ + userId: data.userId, + name: data.name, + description: data.description ?? null, + newCardsPerDay: data.newCardsPerDay ?? 20, + }) + .returning(); + if (!deck) { + throw new Error("Failed to create deck"); + } + return deck; + }, + + async update( + id: string, + userId: string, + data: { + name?: string; + description?: string | null; + newCardsPerDay?: number; + }, + ): Promise<Deck | undefined> { + const result = await db + .update(decks) + .set({ + ...data, + updatedAt: new Date(), + syncVersion: sql`${decks.syncVersion} + 1`, + }) + .where( + and( + eq(decks.id, id), + eq(decks.userId, userId), + isNull(decks.deletedAt), + ), + ) + .returning(); + return result[0]; + }, + + async softDelete(id: string, userId: string): Promise<boolean> { + const result = await db + .update(decks) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + syncVersion: sql`${decks.syncVersion} + 1`, + }) + .where( + and( + eq(decks.id, id), + eq(decks.userId, userId), + isNull(decks.deletedAt), + ), + ) + .returning({ id: decks.id }); + return result.length > 0; + }, +}; diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts index 04b1f35..9a703ab 100644 --- a/src/server/repositories/index.ts +++ b/src/server/repositories/index.ts @@ -1,3 +1,4 @@ +export { deckRepository } from "./deck.js"; export { refreshTokenRepository } from "./refresh-token.js"; export * from "./types.js"; export { userRepository } from "./user.js"; diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 1ab4bdc..740fa29 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -44,3 +44,36 @@ export interface RefreshTokenRepository { }): Promise<void>; deleteById(id: string): Promise<void>; } + +export interface Deck { + id: string; + userId: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +export interface DeckRepository { + findByUserId(userId: string): Promise<Deck[]>; + findById(id: string, userId: string): Promise<Deck | undefined>; + create(data: { + userId: string; + name: string; + description?: string | null; + newCardsPerDay?: number; + }): Promise<Deck>; + update( + id: string, + userId: string, + data: { + name?: string; + description?: string | null; + newCardsPerDay?: number; + }, + ): Promise<Deck | undefined>; + softDelete(id: string, userId: string): Promise<boolean>; +} diff --git a/src/server/routes/decks.test.ts b/src/server/routes/decks.test.ts new file mode 100644 index 0000000..8f5be9d --- /dev/null +++ b/src/server/routes/decks.test.ts @@ -0,0 +1,480 @@ +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import type { Deck, DeckRepository } from "../repositories/index.js"; +import { createDecksRouter } from "./decks.js"; + +function createMockDeckRepo(): DeckRepository { + return { + findByUserId: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + }; +} + +const JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +async function createTestToken(userId: string): Promise<string> { + const now = Math.floor(Date.now() / 1000); + return sign( + { + sub: userId, + iat: now, + exp: now + 900, + }, + JWT_SECRET, + ); +} + +function createMockDeck(overrides: Partial<Deck> = {}): Deck { + return { + id: "deck-uuid-123", + userId: "user-uuid-123", + name: "Test Deck", + description: "Test description", + newCardsPerDay: 20, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +interface DeckResponse { + deck?: Deck; + decks?: Deck[]; + success?: boolean; + error?: { + code: string; + message: string; + }; +} + +describe("GET /api/decks", () => { + let app: Hono; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDeckRepo = createMockDeckRepo(); + const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks", decksRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns empty array when user has no decks", async () => { + vi.mocked(mockDeckRepo.findByUserId).mockResolvedValue([]); + + const res = await app.request("/api/decks", { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as DeckResponse; + expect(body.decks).toEqual([]); + expect(mockDeckRepo.findByUserId).toHaveBeenCalledWith("user-uuid-123"); + }); + + it("returns user decks", async () => { + const mockDecks = [ + createMockDeck({ id: "deck-1", name: "Deck 1" }), + createMockDeck({ id: "deck-2", name: "Deck 2" }), + ]; + vi.mocked(mockDeckRepo.findByUserId).mockResolvedValue(mockDecks); + + const res = await app.request("/api/decks", { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as DeckResponse; + expect(body.decks).toHaveLength(2); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request("/api/decks", { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/decks", () => { + let app: Hono; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDeckRepo = createMockDeckRepo(); + const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks", decksRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("creates a new deck with required fields", async () => { + const newDeck = createMockDeck({ name: "New Deck" }); + vi.mocked(mockDeckRepo.create).mockResolvedValue(newDeck); + + const res = await app.request("/api/decks", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "New Deck" }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as DeckResponse; + expect(body.deck?.name).toBe("New Deck"); + expect(mockDeckRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + name: "New Deck", + description: undefined, + newCardsPerDay: 20, + }); + }); + + it("creates a new deck with all fields", async () => { + const newDeck = createMockDeck({ + name: "Full Deck", + description: "Full description", + newCardsPerDay: 30, + }); + vi.mocked(mockDeckRepo.create).mockResolvedValue(newDeck); + + const res = await app.request("/api/decks", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Full Deck", + description: "Full description", + newCardsPerDay: 30, + }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as DeckResponse; + expect(body.deck?.name).toBe("Full Deck"); + expect(mockDeckRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + name: "Full Deck", + description: "Full description", + newCardsPerDay: 30, + }); + }); + + it("returns 400 for missing name", async () => { + const res = await app.request("/api/decks", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for empty name", async () => { + const res = await app.request("/api/decks", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request("/api/decks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/decks/:id", () => { + let app: Hono; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDeckRepo = createMockDeckRepo(); + const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks", decksRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns deck by id", async () => { + const deckId = "a0000000-0000-4000-8000-000000000001"; + const mockDeck = createMockDeck({ id: deckId }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue(mockDeck); + + const res = await app.request(`/api/decks/${deckId}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as DeckResponse; + expect(body.deck?.id).toBe(deckId); + expect(mockDeckRepo.findById).toHaveBeenCalledWith(deckId, "user-uuid-123"); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as DeckResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 400 for invalid uuid", async () => { + const res = await app.request("/api/decks/invalid-id", { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request("/api/decks/deck-uuid-123", { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/decks/:id", () => { + let app: Hono; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDeckRepo = createMockDeckRepo(); + const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks", decksRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("updates deck name", async () => { + const updatedDeck = createMockDeck({ name: "Updated Name" }); + vi.mocked(mockDeckRepo.update).mockResolvedValue(updatedDeck); + + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Updated Name" }), + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as DeckResponse; + expect(body.deck?.name).toBe("Updated Name"); + }); + + it("updates deck description", async () => { + const updatedDeck = createMockDeck({ description: "New description" }); + vi.mocked(mockDeckRepo.update).mockResolvedValue(updatedDeck); + + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ description: "New description" }), + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as DeckResponse; + expect(body.deck?.description).toBe("New description"); + }); + + it("updates newCardsPerDay", async () => { + const updatedDeck = createMockDeck({ newCardsPerDay: 50 }); + vi.mocked(mockDeckRepo.update).mockResolvedValue(updatedDeck); + + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ newCardsPerDay: 50 }), + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as DeckResponse; + expect(body.deck?.newCardsPerDay).toBe(50); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.update).mockResolvedValue(undefined); + + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Test" }), + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as DeckResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 400 for invalid uuid", async () => { + const res = await app.request("/api/decks/invalid-id", { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Test" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }, + ); + + expect(res.status).toBe(401); + }); +}); + +describe("DELETE /api/decks/:id", () => { + let app: Hono; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDeckRepo = createMockDeckRepo(); + const decksRouter = createDecksRouter({ deckRepo: mockDeckRepo }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks", decksRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("deletes deck successfully", async () => { + vi.mocked(mockDeckRepo.softDelete).mockResolvedValue(true); + + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as DeckResponse; + expect(body.success).toBe(true); + expect(mockDeckRepo.softDelete).toHaveBeenCalledWith( + "00000000-0000-0000-0000-000000000000", + "user-uuid-123", + ); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.softDelete).mockResolvedValue(false); + + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as DeckResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 400 for invalid uuid", async () => { + const res = await app.request("/api/decks/invalid-id", { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request( + "/api/decks/00000000-0000-0000-0000-000000000000", + { + method: "DELETE", + }, + ); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/server/routes/decks.ts b/src/server/routes/decks.ts new file mode 100644 index 0000000..4604ea9 --- /dev/null +++ b/src/server/routes/decks.ts @@ -0,0 +1,82 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js"; +import { type DeckRepository, deckRepository } from "../repositories/index.js"; +import { createDeckSchema, updateDeckSchema } from "../schemas/index.js"; + +export interface DeckDependencies { + deckRepo: DeckRepository; +} + +const deckIdParamSchema = z.object({ + id: z.string().uuid(), +}); + +export function createDecksRouter(deps: DeckDependencies) { + const { deckRepo } = deps; + + return new Hono() + .use("*", authMiddleware) + .get("/", async (c) => { + const user = getAuthUser(c); + const decks = await deckRepo.findByUserId(user.id); + return c.json({ decks }, 200); + }) + .post("/", zValidator("json", createDeckSchema), async (c) => { + const user = getAuthUser(c); + const data = c.req.valid("json"); + + const deck = await deckRepo.create({ + userId: user.id, + name: data.name, + description: data.description, + newCardsPerDay: data.newCardsPerDay, + }); + + return c.json({ deck }, 201); + }) + .get("/:id", zValidator("param", deckIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + + const deck = await deckRepo.findById(id, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + return c.json({ deck }, 200); + }) + .put( + "/:id", + zValidator("param", deckIdParamSchema), + zValidator("json", updateDeckSchema), + async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + const data = c.req.valid("json"); + + const deck = await deckRepo.update(id, user.id, data); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + return c.json({ deck }, 200); + }, + ) + .delete("/:id", zValidator("param", deckIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + + const deleted = await deckRepo.softDelete(id, user.id); + if (!deleted) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + return c.json({ success: true }, 200); + }); +} + +export const decks = createDecksRouter({ + deckRepo: deckRepository, +}); diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 0b89782..43a3a07 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1 +1,2 @@ export { auth } from "./auth.js"; +export { decks } from "./decks.js"; |
