diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:14:02 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:14:02 +0900 |
| commit | 0b0e7e802fcb50652c3e9912363d996a039d56d8 (patch) | |
| tree | a3fc98441f638159ddedf98c637ed6045c2de382 | |
| parent | 2ca8bfadd49fb8e5f45b6324cff13c35a2858bb7 (diff) | |
| download | kioku-0b0e7e802fcb50652c3e9912363d996a039d56d8.tar.gz kioku-0b0e7e802fcb50652c3e9912363d996a039d56d8.tar.zst kioku-0b0e7e802fcb50652c3e9912363d996a039d56d8.zip | |
feat(server): add card CRUD endpoints
Implement card management API with create, read, update, and delete
operations. Cards are nested under decks (/api/decks/:deckId/cards)
with deck ownership verification on all operations.
- Add Card interface and CardRepository to repository types
- Create cardRepository with CRUD operations and soft delete
- Add card routes with Zod validation and auth middleware
- Include 29 tests covering all endpoints 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/card.ts | 102 | ||||
| -rw-r--r-- | src/server/repositories/index.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 44 | ||||
| -rw-r--r-- | src/server/routes/cards.test.ts | 678 | ||||
| -rw-r--r-- | src/server/routes/cards.ts | 130 | ||||
| -rw-r--r-- | src/server/routes/index.ts | 1 |
8 files changed, 961 insertions, 4 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 78325db..4c2ccba 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -99,8 +99,8 @@ Smaller features first to enable early MVP validation. **Goal**: Create, edit, and delete cards ### Server API -- [ ] Card CRUD endpoints (GET, POST, PUT, DELETE) -- [ ] Add tests +- [x] Card CRUD endpoints (GET, POST, PUT, DELETE) +- [x] Add tests ### Frontend - [ ] Card list view (in deck detail page) diff --git a/src/server/index.ts b/src/server/index.ts index bcedb4e..d00564f 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, decks } from "./routes/index.js"; +import { auth, cards, decks } from "./routes/index.js"; const app = new Hono(); @@ -18,7 +18,8 @@ const routes = app return c.json({ status: "ok" }, 200); }) .route("/api/auth", auth) - .route("/api/decks", decks); + .route("/api/decks", decks) + .route("/api/decks/:deckId/cards", cards); export type AppType = typeof routes; diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts new file mode 100644 index 0000000..0a47c50 --- /dev/null +++ b/src/server/repositories/card.ts @@ -0,0 +1,102 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { CardState, cards } from "../db/schema.js"; +import type { Card, CardRepository } from "./types.js"; + +export const cardRepository: CardRepository = { + async findByDeckId(deckId: string): Promise<Card[]> { + const result = await db + .select() + .from(cards) + .where(and(eq(cards.deckId, deckId), isNull(cards.deletedAt))); + return result; + }, + + async findById(id: string, deckId: string): Promise<Card | undefined> { + const result = await db + .select() + .from(cards) + .where( + and( + eq(cards.id, id), + eq(cards.deckId, deckId), + isNull(cards.deletedAt), + ), + ); + return result[0]; + }, + + async create( + deckId: string, + data: { + front: string; + back: string; + }, + ): Promise<Card> { + const [card] = await db + .insert(cards) + .values({ + deckId, + front: data.front, + back: data.back, + state: CardState.New, + due: new Date(), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + }) + .returning(); + if (!card) { + throw new Error("Failed to create card"); + } + return card; + }, + + async update( + id: string, + deckId: string, + data: { + front?: string; + back?: string; + }, + ): Promise<Card | undefined> { + const result = await db + .update(cards) + .set({ + ...data, + updatedAt: new Date(), + syncVersion: sql`${cards.syncVersion} + 1`, + }) + .where( + and( + eq(cards.id, id), + eq(cards.deckId, deckId), + isNull(cards.deletedAt), + ), + ) + .returning(); + return result[0]; + }, + + async softDelete(id: string, deckId: string): Promise<boolean> { + const result = await db + .update(cards) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + syncVersion: sql`${cards.syncVersion} + 1`, + }) + .where( + and( + eq(cards.id, id), + eq(cards.deckId, deckId), + isNull(cards.deletedAt), + ), + ) + .returning({ id: cards.id }); + return result.length > 0; + }, +}; diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts index 9a703ab..298666e 100644 --- a/src/server/repositories/index.ts +++ b/src/server/repositories/index.ts @@ -1,3 +1,4 @@ +export { cardRepository } from "./card.js"; export { deckRepository } from "./deck.js"; export { refreshTokenRepository } from "./refresh-token.js"; export * from "./types.js"; diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 740fa29..1e8ba21 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -77,3 +77,47 @@ export interface DeckRepository { ): Promise<Deck | undefined>; softDelete(id: string, userId: string): Promise<boolean>; } + +export interface Card { + id: string; + deckId: string; + front: string; + back: string; + + // FSRS fields + state: number; + due: Date; + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: Date | null; + + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +export interface CardRepository { + findByDeckId(deckId: string): Promise<Card[]>; + findById(id: string, deckId: string): Promise<Card | undefined>; + create( + deckId: string, + data: { + front: string; + back: string; + }, + ): Promise<Card>; + update( + id: string, + deckId: string, + data: { + front?: string; + back?: string; + }, + ): Promise<Card | undefined>; + softDelete(id: string, deckId: string): Promise<boolean>; +} diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts new file mode 100644 index 0000000..1d01cff --- /dev/null +++ b/src/server/routes/cards.test.ts @@ -0,0 +1,678 @@ +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CardState } from "../db/schema.js"; +import { errorHandler } from "../middleware/index.js"; +import type { + Card, + CardRepository, + Deck, + DeckRepository, +} from "../repositories/index.js"; +import { createCardsRouter } from "./cards.js"; + +function createMockCardRepo(): CardRepository { + return { + findByDeckId: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + }; +} + +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, + }; +} + +function createMockCard(overrides: Partial<Card> = {}): Card { + return { + id: "card-uuid-123", + deckId: "deck-uuid-123", + front: "Question", + back: "Answer", + state: CardState.New, + due: new Date("2024-01-01"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +interface CardResponse { + card?: Card; + cards?: Card[]; + success?: boolean; + error?: { + code: string; + message: string; + }; +} + +const DECK_ID = "00000000-0000-4000-8000-000000000001"; +const CARD_ID = "00000000-0000-4000-8000-000000000002"; + +describe("GET /api/decks/:deckId/cards", () => { + let app: Hono; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns empty array when deck has no cards", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findByDeckId).mockResolvedValue([]); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.cards).toEqual([]); + expect(mockDeckRepo.findById).toHaveBeenCalledWith( + DECK_ID, + "user-uuid-123", + ); + expect(mockCardRepo.findByDeckId).toHaveBeenCalledWith(DECK_ID); + }); + + it("returns cards for deck", async () => { + const mockCards = [ + createMockCard({ id: "card-1", front: "Q1", back: "A1" }), + createMockCard({ id: "card-2", front: "Q2", back: "A2" }), + ]; + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findByDeckId).mockResolvedValue(mockCards); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.cards).toHaveLength(2); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 400 for invalid deck uuid", async () => { + const res = await app.request("/api/decks/invalid-id/cards", { + 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_ID}/cards`, { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/decks/:deckId/cards", () => { + let app: Hono; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("creates a new card", async () => { + const newCard = createMockCard({ + deckId: DECK_ID, + front: "New Question", + back: "New Answer", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.create).mockResolvedValue(newCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "New Question", back: "New Answer" }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as CardResponse; + expect(body.card?.front).toBe("New Question"); + expect(body.card?.back).toBe("New Answer"); + expect(mockCardRepo.create).toHaveBeenCalledWith(DECK_ID, { + front: "New Question", + back: "New Answer", + }); + }); + + it("returns 400 for missing front", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ back: "Answer" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for missing back", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Question" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for empty front", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "", back: "Answer" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for empty back", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Question", back: "" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Question", back: "Answer" }), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request(`/api/decks/${DECK_ID}/cards`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ front: "Question", back: "Answer" }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/decks/:deckId/cards/:cardId", () => { + let app: Hono; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns card by id", async () => { + const mockCard = createMockCard({ id: CARD_ID, deckId: DECK_ID }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findById).mockResolvedValue(mockCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.id).toBe(CARD_ID); + expect(mockCardRepo.findById).toHaveBeenCalledWith(CARD_ID, DECK_ID); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 404 for non-existent card", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("CARD_NOT_FOUND"); + }); + + it("returns 400 for invalid card uuid", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/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_ID}/cards/${CARD_ID}`, { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/decks/:deckId/cards/:cardId", () => { + let app: Hono; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("updates card front", async () => { + const updatedCard = createMockCard({ + id: CARD_ID, + deckId: DECK_ID, + front: "Updated Question", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(updatedCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Updated Question" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.front).toBe("Updated Question"); + }); + + it("updates card back", async () => { + const updatedCard = createMockCard({ + id: CARD_ID, + deckId: DECK_ID, + back: "Updated Answer", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(updatedCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ back: "Updated Answer" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.back).toBe("Updated Answer"); + }); + + it("updates both front and back", async () => { + const updatedCard = createMockCard({ + id: CARD_ID, + deckId: DECK_ID, + front: "New Q", + back: "New A", + }); + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(updatedCard); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "New Q", back: "New A" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.card?.front).toBe("New Q"); + expect(body.card?.back).toBe("New A"); + expect(mockCardRepo.update).toHaveBeenCalledWith(CARD_ID, DECK_ID, { + front: "New Q", + back: "New A", + }); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 404 for non-existent card", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.update).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("CARD_NOT_FOUND"); + }); + + it("returns 400 for invalid card uuid", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/invalid-id`, { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ front: "Test" }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe("DELETE /api/decks/:deckId/cards/:cardId", () => { + let app: Hono; + let mockCardRepo: ReturnType<typeof createMockCardRepo>; + let mockDeckRepo: ReturnType<typeof createMockDeckRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCardRepo = createMockCardRepo(); + mockDeckRepo = createMockDeckRepo(); + const cardsRouter = createCardsRouter({ + cardRepo: mockCardRepo, + deckRepo: mockDeckRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/decks/:deckId/cards", cardsRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("deletes card successfully", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.softDelete).mockResolvedValue(true); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as CardResponse; + expect(body.success).toBe(true); + expect(mockCardRepo.softDelete).toHaveBeenCalledWith(CARD_ID, DECK_ID); + }); + + it("returns 404 for non-existent deck", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("DECK_NOT_FOUND"); + }); + + it("returns 404 for non-existent card", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + vi.mocked(mockCardRepo.softDelete).mockResolvedValue(false); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/${CARD_ID}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(404); + const body = (await res.json()) as CardResponse; + expect(body.error?.code).toBe("CARD_NOT_FOUND"); + }); + + it("returns 400 for invalid card uuid", async () => { + vi.mocked(mockDeckRepo.findById).mockResolvedValue( + createMockDeck({ id: DECK_ID }), + ); + + const res = await app.request(`/api/decks/${DECK_ID}/cards/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/${DECK_ID}/cards/${CARD_ID}`, { + method: "DELETE", + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/server/routes/cards.ts b/src/server/routes/cards.ts new file mode 100644 index 0000000..6fb259b --- /dev/null +++ b/src/server/routes/cards.ts @@ -0,0 +1,130 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js"; +import { + type CardRepository, + cardRepository, + type DeckRepository, + deckRepository, +} from "../repositories/index.js"; +import { createCardSchema, updateCardSchema } from "../schemas/index.js"; + +export interface CardDependencies { + cardRepo: CardRepository; + deckRepo: DeckRepository; +} + +const deckIdParamSchema = z.object({ + deckId: z.string().uuid(), +}); + +const cardIdParamSchema = z.object({ + deckId: z.string().uuid(), + cardId: z.string().uuid(), +}); + +export function createCardsRouter(deps: CardDependencies) { + const { cardRepo, deckRepo } = deps; + + return new Hono() + .use("*", authMiddleware) + .get("/", zValidator("param", deckIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { deckId } = c.req.valid("param"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const cards = await cardRepo.findByDeckId(deckId); + return c.json({ cards }, 200); + }) + .post( + "/", + zValidator("param", deckIdParamSchema), + zValidator("json", createCardSchema), + async (c) => { + const user = getAuthUser(c); + const { deckId } = c.req.valid("param"); + const data = c.req.valid("json"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const card = await cardRepo.create(deckId, { + front: data.front, + back: data.back, + }); + + return c.json({ card }, 201); + }, + ) + .get("/:cardId", zValidator("param", cardIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { deckId, cardId } = c.req.valid("param"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const card = await cardRepo.findById(cardId, deckId); + if (!card) { + throw Errors.notFound("Card not found", "CARD_NOT_FOUND"); + } + + return c.json({ card }, 200); + }) + .put( + "/:cardId", + zValidator("param", cardIdParamSchema), + zValidator("json", updateCardSchema), + async (c) => { + const user = getAuthUser(c); + const { deckId, cardId } = c.req.valid("param"); + const data = c.req.valid("json"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const card = await cardRepo.update(cardId, deckId, data); + if (!card) { + throw Errors.notFound("Card not found", "CARD_NOT_FOUND"); + } + + return c.json({ card }, 200); + }, + ) + .delete("/:cardId", zValidator("param", cardIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { deckId, cardId } = c.req.valid("param"); + + // Verify deck ownership + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const deleted = await cardRepo.softDelete(cardId, deckId); + if (!deleted) { + throw Errors.notFound("Card not found", "CARD_NOT_FOUND"); + } + + return c.json({ success: true }, 200); + }); +} + +export const cards = createCardsRouter({ + cardRepo: cardRepository, + deckRepo: deckRepository, +}); diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 43a3a07..009f2c3 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,2 +1,3 @@ export { auth } from "./auth.js"; +export { cards } from "./cards.js"; export { decks } from "./decks.js"; |
