aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 18:14:02 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 18:14:02 +0900
commit0b0e7e802fcb50652c3e9912363d996a039d56d8 (patch)
treea3fc98441f638159ddedf98c637ed6045c2de382
parent2ca8bfadd49fb8e5f45b6324cff13c35a2858bb7 (diff)
downloadkioku-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.md4
-rw-r--r--src/server/index.ts5
-rw-r--r--src/server/repositories/card.ts102
-rw-r--r--src/server/repositories/index.ts1
-rw-r--r--src/server/repositories/types.ts44
-rw-r--r--src/server/routes/cards.test.ts678
-rw-r--r--src/server/routes/cards.ts130
-rw-r--r--src/server/routes/index.ts1
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";