diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 01:04:44 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 01:04:44 +0900 |
| commit | 9165e1d82c49f247d0d2aa763395a199d9b3f8e4 (patch) | |
| tree | fa40320377196f7de19aa8e135197bd219cda21f /src | |
| parent | c77da463a60061877cd7ddea1bd7b723b3bf2455 (diff) | |
| download | kioku-9165e1d82c49f247d0d2aa763395a199d9b3f8e4.tar.gz kioku-9165e1d82c49f247d0d2aa763395a199d9b3f8e4.tar.zst kioku-9165e1d82c49f247d0d2aa763395a199d9b3f8e4.zip | |
feat(api): add NoteType and NoteFieldType API routes
Implement REST API endpoints for managing note types and their fields:
- CRUD operations for note types (/api/note-types)
- Field management nested under note types (add, update, delete, reorder)
- Constraint checks to prevent deletion of note types with existing notes
- Constraint checks to prevent deletion of fields with existing values
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/server/index.ts | 3 | ||||
| -rw-r--r-- | src/server/routes/index.ts | 1 | ||||
| -rw-r--r-- | src/server/routes/noteTypes.test.ts | 1026 | ||||
| -rw-r--r-- | src/server/routes/noteTypes.ts | 221 |
4 files changed, 1250 insertions, 1 deletions
diff --git a/src/server/index.ts b/src/server/index.ts index ad7f48a..91f76fb 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 { createCorsMiddleware, errorHandler } from "./middleware/index.js"; -import { auth, cards, decks, study, sync } from "./routes/index.js"; +import { auth, cards, decks, noteTypes, study, sync } from "./routes/index.js"; const app = new Hono(); @@ -22,6 +22,7 @@ const routes = app .route("/api/decks", decks) .route("/api/decks/:deckId/cards", cards) .route("/api/decks/:deckId/study", study) + .route("/api/note-types", noteTypes) .route("/api/sync", sync); export type AppType = typeof routes; diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 08f42df..74f239f 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,5 +1,6 @@ export { auth } from "./auth.js"; export { cards } from "./cards.js"; export { decks } from "./decks.js"; +export { noteTypes } from "./noteTypes.js"; export { study } from "./study.js"; export { sync } from "./sync.js"; diff --git a/src/server/routes/noteTypes.test.ts b/src/server/routes/noteTypes.test.ts new file mode 100644 index 0000000..ccc29af --- /dev/null +++ b/src/server/routes/noteTypes.test.ts @@ -0,0 +1,1026 @@ +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 { + NoteFieldType, + NoteFieldTypeRepository, + NoteType, + NoteTypeRepository, + NoteTypeWithFields, +} from "../repositories/index.js"; +import { createNoteTypesRouter } from "./noteTypes.js"; + +function createMockNoteTypeRepo(): NoteTypeRepository { + return { + findByUserId: vi.fn(), + findById: vi.fn(), + findByIdWithFields: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + hasNotes: vi.fn(), + }; +} + +function createMockNoteFieldTypeRepo(): NoteFieldTypeRepository { + return { + findByNoteTypeId: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + reorder: vi.fn(), + hasNoteFieldValues: 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 createMockNoteType(overrides: Partial<NoteType> = {}): NoteType { + return { + id: "note-type-uuid-123", + userId: "user-uuid-123", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +function createMockNoteFieldType( + overrides: Partial<NoteFieldType> = {}, +): NoteFieldType { + return { + id: "field-uuid-123", + noteTypeId: "note-type-uuid-123", + name: "Front", + order: 0, + fieldType: "text", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +interface NoteTypeResponse { + noteType?: NoteType | NoteTypeWithFields; + noteTypes?: NoteType[]; + field?: NoteFieldType; + fields?: NoteFieldType[]; + success?: boolean; + error?: { + code: string; + message: string; + }; +} + +describe("GET /api/note-types", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns empty array when user has no note types", async () => { + vi.mocked(mockNoteTypeRepo.findByUserId).mockResolvedValue([]); + + const res = await app.request("/api/note-types", { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.noteTypes).toEqual([]); + expect(mockNoteTypeRepo.findByUserId).toHaveBeenCalledWith("user-uuid-123"); + }); + + it("returns user note types", async () => { + const mockNoteTypes = [ + createMockNoteType({ id: "type-1", name: "Basic" }), + createMockNoteType({ + id: "type-2", + name: "Basic (and reversed)", + isReversible: true, + }), + ]; + vi.mocked(mockNoteTypeRepo.findByUserId).mockResolvedValue(mockNoteTypes); + + const res = await app.request("/api/note-types", { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.noteTypes).toHaveLength(2); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request("/api/note-types", { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/note-types", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("creates a new note type", async () => { + const newNoteType = createMockNoteType({ name: "Custom Type" }); + vi.mocked(mockNoteTypeRepo.create).mockResolvedValue(newNoteType); + + const res = await app.request("/api/note-types", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Custom Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as NoteTypeResponse; + expect(body.noteType?.name).toBe("Custom Type"); + expect(mockNoteTypeRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + name: "Custom Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + }); + + it("creates a reversible note type", async () => { + const newNoteType = createMockNoteType({ + name: "Reversible", + isReversible: true, + }); + vi.mocked(mockNoteTypeRepo.create).mockResolvedValue(newNoteType); + + const res = await app.request("/api/note-types", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Reversible", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as NoteTypeResponse; + expect(body.noteType?.isReversible).toBe(true); + }); + + it("returns 400 for missing required fields", async () => { + const res = await app.request("/api/note-types", { + method: "POST", + 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/note-types", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Test", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/note-types/:id", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("returns note type with fields", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const mockNoteType: NoteTypeWithFields = { + ...createMockNoteType({ id: noteTypeId }), + fields: [ + createMockNoteFieldType({ id: "field-1", name: "Front", order: 0 }), + createMockNoteFieldType({ id: "field-2", name: "Back", order: 1 }), + ], + }; + vi.mocked(mockNoteTypeRepo.findByIdWithFields).mockResolvedValue( + mockNoteType, + ); + + const res = await app.request(`/api/note-types/${noteTypeId}`, { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.noteType?.id).toBe(noteTypeId); + expect((body.noteType as NoteTypeWithFields)?.fields).toHaveLength(2); + expect(mockNoteTypeRepo.findByIdWithFields).toHaveBeenCalledWith( + noteTypeId, + "user-uuid-123", + ); + }); + + it("returns 404 for non-existent note type", async () => { + vi.mocked(mockNoteTypeRepo.findByIdWithFields).mockResolvedValue(undefined); + + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000", + { + method: "GET", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND"); + }); + + it("returns 400 for invalid uuid", async () => { + const res = await app.request("/api/note-types/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/note-types/note-type-uuid-123", { + method: "GET", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/note-types/:id", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("updates note type name", async () => { + const updatedNoteType = createMockNoteType({ name: "Updated Name" }); + vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(updatedNoteType); + + const res = await app.request( + "/api/note-types/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 NoteTypeResponse; + expect(body.noteType?.name).toBe("Updated Name"); + }); + + it("updates note type templates", async () => { + const updatedNoteType = createMockNoteType({ + frontTemplate: "Q: {{Front}}", + backTemplate: "A: {{Back}}", + }); + vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(updatedNoteType); + + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000", + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + frontTemplate: "Q: {{Front}}", + backTemplate: "A: {{Back}}", + }), + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.noteType?.frontTemplate).toBe("Q: {{Front}}"); + expect(body.noteType?.backTemplate).toBe("A: {{Back}}"); + }); + + it("updates isReversible flag", async () => { + const updatedNoteType = createMockNoteType({ isReversible: true }); + vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(updatedNoteType); + + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000", + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ isReversible: true }), + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.noteType?.isReversible).toBe(true); + }); + + it("returns 404 for non-existent note type", async () => { + vi.mocked(mockNoteTypeRepo.update).mockResolvedValue(undefined); + + const res = await app.request( + "/api/note-types/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 NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND"); + }); + + it("returns 400 for invalid uuid", async () => { + const res = await app.request("/api/note-types/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/note-types/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/note-types/:id", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("deletes note type successfully", async () => { + vi.mocked(mockNoteTypeRepo.hasNotes).mockResolvedValue(false); + vi.mocked(mockNoteTypeRepo.softDelete).mockResolvedValue(true); + + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000", + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.success).toBe(true); + expect(mockNoteTypeRepo.softDelete).toHaveBeenCalledWith( + "00000000-0000-0000-0000-000000000000", + "user-uuid-123", + ); + }); + + it("returns 409 when note type has notes", async () => { + vi.mocked(mockNoteTypeRepo.hasNotes).mockResolvedValue(true); + + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000", + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(409); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_HAS_NOTES"); + }); + + it("returns 404 for non-existent note type", async () => { + vi.mocked(mockNoteTypeRepo.hasNotes).mockResolvedValue(false); + vi.mocked(mockNoteTypeRepo.softDelete).mockResolvedValue(false); + + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000", + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND"); + }); + + it("returns 400 for invalid uuid", async () => { + const res = await app.request("/api/note-types/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/note-types/00000000-0000-0000-0000-000000000000", + { + method: "DELETE", + }, + ); + + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/note-types/:id/fields", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("creates a new field", async () => { + const noteTypeId = "00000000-0000-0000-0000-000000000000"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + const newField = createMockNoteFieldType({ + noteTypeId, + name: "Extra", + order: 2, + }); + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.create).mockResolvedValue(newField); + + const res = await app.request(`/api/note-types/${noteTypeId}/fields`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Extra", order: 2 }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as NoteTypeResponse; + expect(body.field?.name).toBe("Extra"); + expect(mockNoteFieldTypeRepo.create).toHaveBeenCalledWith(noteTypeId, { + name: "Extra", + order: 2, + fieldType: "text", + }); + }); + + it("returns 404 when note type not found", async () => { + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined); + + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000/fields", + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Extra", order: 2 }), + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND"); + }); + + it("returns 400 for missing required fields", async () => { + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000/fields", + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Extra" }), + }, + ); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request( + "/api/note-types/00000000-0000-0000-0000-000000000000/fields", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Extra", order: 2 }), + }, + ); + + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/note-types/:id/fields/:fieldId", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("updates field name", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + const updatedField = createMockNoteFieldType({ + id: fieldId, + noteTypeId, + name: "Updated Name", + }); + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.update).mockResolvedValue(updatedField); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}`, + { + 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 NoteTypeResponse; + expect(body.field?.name).toBe("Updated Name"); + }); + + it("returns 404 when note type not found", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Updated" }), + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND"); + }); + + it("returns 404 when field not found", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.update).mockResolvedValue(undefined); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Updated" }), + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("FIELD_NOT_FOUND"); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request( + "/api/note-types/a0000000-0000-4000-8000-000000000001/fields/b0000000-0000-4000-8000-000000000002", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Updated" }), + }, + ); + + expect(res.status).toBe(401); + }); +}); + +describe("DELETE /api/note-types/:id/fields/:fieldId", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("deletes field successfully", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue( + false, + ); + vi.mocked(mockNoteFieldTypeRepo.softDelete).mockResolvedValue(true); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.success).toBe(true); + expect(mockNoteFieldTypeRepo.softDelete).toHaveBeenCalledWith( + fieldId, + noteTypeId, + ); + }); + + it("returns 409 when field has values", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue(true); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(409); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("FIELD_HAS_VALUES"); + }); + + it("returns 404 when note type not found", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND"); + }); + + it("returns 404 when field not found", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId = "b0000000-0000-4000-8000-000000000002"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.hasNoteFieldValues).mockResolvedValue( + false, + ); + vi.mocked(mockNoteFieldTypeRepo.softDelete).mockResolvedValue(false); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/${fieldId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${authToken}` }, + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("FIELD_NOT_FOUND"); + }); + + it("returns 401 when not authenticated", async () => { + const res = await app.request( + "/api/note-types/a0000000-0000-4000-8000-000000000001/fields/b0000000-0000-4000-8000-000000000002", + { + method: "DELETE", + }, + ); + + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/note-types/:id/fields/reorder", () => { + let app: Hono; + let mockNoteTypeRepo: ReturnType<typeof createMockNoteTypeRepo>; + let mockNoteFieldTypeRepo: ReturnType<typeof createMockNoteFieldTypeRepo>; + let authToken: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockNoteTypeRepo = createMockNoteTypeRepo(); + mockNoteFieldTypeRepo = createMockNoteFieldTypeRepo(); + const noteTypesRouter = createNoteTypesRouter({ + noteTypeRepo: mockNoteTypeRepo, + noteFieldTypeRepo: mockNoteFieldTypeRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/note-types", noteTypesRouter); + authToken = await createTestToken("user-uuid-123"); + }); + + it("reorders fields successfully", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId1 = "b0000000-0000-4000-8000-000000000002"; + const fieldId2 = "c0000000-0000-4000-8000-000000000003"; + const mockNoteType = createMockNoteType({ id: noteTypeId }); + const reorderedFields = [ + createMockNoteFieldType({ id: fieldId2, order: 0 }), + createMockNoteFieldType({ id: fieldId1, order: 1 }), + ]; + + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(mockNoteType); + vi.mocked(mockNoteFieldTypeRepo.reorder).mockResolvedValue(reorderedFields); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/reorder`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ fieldIds: [fieldId2, fieldId1] }), + }, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as NoteTypeResponse; + expect(body.fields).toHaveLength(2); + expect(mockNoteFieldTypeRepo.reorder).toHaveBeenCalledWith(noteTypeId, [ + fieldId2, + fieldId1, + ]); + }); + + it("returns 404 when note type not found", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId1 = "b0000000-0000-4000-8000-000000000002"; + const fieldId2 = "c0000000-0000-4000-8000-000000000003"; + vi.mocked(mockNoteTypeRepo.findById).mockResolvedValue(undefined); + + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/reorder`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + fieldIds: [fieldId1, fieldId2], + }), + }, + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as NoteTypeResponse; + expect(body.error?.code).toBe("NOTE_TYPE_NOT_FOUND"); + }); + + it("returns 400 for invalid fieldIds", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/reorder`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ fieldIds: ["invalid-id"] }), + }, + ); + + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + const noteTypeId = "a0000000-0000-4000-8000-000000000001"; + const fieldId1 = "b0000000-0000-4000-8000-000000000002"; + const fieldId2 = "c0000000-0000-4000-8000-000000000003"; + const res = await app.request( + `/api/note-types/${noteTypeId}/fields/reorder`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fieldIds: [fieldId1, fieldId2], + }), + }, + ); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/server/routes/noteTypes.ts b/src/server/routes/noteTypes.ts new file mode 100644 index 0000000..7ab5aa0 --- /dev/null +++ b/src/server/routes/noteTypes.ts @@ -0,0 +1,221 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js"; +import { + type NoteFieldTypeRepository, + type NoteTypeRepository, + noteFieldTypeRepository, + noteTypeRepository, +} from "../repositories/index.js"; +import { + createNoteFieldTypeSchema, + createNoteTypeSchema, + updateNoteFieldTypeSchema, + updateNoteTypeSchema, +} from "../schemas/index.js"; + +export interface NoteTypeDependencies { + noteTypeRepo: NoteTypeRepository; + noteFieldTypeRepo: NoteFieldTypeRepository; +} + +const noteTypeIdParamSchema = z.object({ + id: z.uuid(), +}); + +const noteTypeFieldIdParamSchema = z.object({ + id: z.uuid(), + fieldId: z.uuid(), +}); + +const reorderFieldsSchema = z.object({ + fieldIds: z.array(z.uuid()), +}); + +export function createNoteTypesRouter(deps: NoteTypeDependencies) { + const { noteTypeRepo, noteFieldTypeRepo } = deps; + + return ( + new Hono() + .use("*", authMiddleware) + // List user's note types + .get("/", async (c) => { + const user = getAuthUser(c); + const noteTypes = await noteTypeRepo.findByUserId(user.id); + return c.json({ noteTypes }, 200); + }) + // Create note type + .post("/", zValidator("json", createNoteTypeSchema), async (c) => { + const user = getAuthUser(c); + const data = c.req.valid("json"); + + const noteType = await noteTypeRepo.create({ + userId: user.id, + name: data.name, + frontTemplate: data.frontTemplate, + backTemplate: data.backTemplate, + isReversible: data.isReversible, + }); + + return c.json({ noteType }, 201); + }) + // Get note type with fields + .get("/:id", zValidator("param", noteTypeIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + + const noteType = await noteTypeRepo.findByIdWithFields(id, user.id); + if (!noteType) { + throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND"); + } + + return c.json({ noteType }, 200); + }) + // Update note type + .put( + "/:id", + zValidator("param", noteTypeIdParamSchema), + zValidator("json", updateNoteTypeSchema), + async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + const data = c.req.valid("json"); + + const noteType = await noteTypeRepo.update(id, user.id, data); + if (!noteType) { + throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND"); + } + + return c.json({ noteType }, 200); + }, + ) + // Delete note type (soft delete) + .delete("/:id", zValidator("param", noteTypeIdParamSchema), async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + + // Check if there are notes referencing this note type + const hasNotes = await noteTypeRepo.hasNotes(id, user.id); + if (hasNotes) { + throw Errors.conflict( + "Cannot delete note type with existing notes", + "NOTE_TYPE_HAS_NOTES", + ); + } + + const deleted = await noteTypeRepo.softDelete(id, user.id); + if (!deleted) { + throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND"); + } + + return c.json({ success: true }, 200); + }) + // Add field to note type + .post( + "/:id/fields", + zValidator("param", noteTypeIdParamSchema), + zValidator("json", createNoteFieldTypeSchema), + async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + const data = c.req.valid("json"); + + // Verify note type exists and belongs to user + const noteType = await noteTypeRepo.findById(id, user.id); + if (!noteType) { + throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND"); + } + + const field = await noteFieldTypeRepo.create(id, { + name: data.name, + order: data.order, + fieldType: data.fieldType, + }); + + return c.json({ field }, 201); + }, + ) + // Reorder fields (must come before /:id/fields/:fieldId to avoid matching "reorder" as fieldId) + .put( + "/:id/fields/reorder", + zValidator("param", noteTypeIdParamSchema), + zValidator("json", reorderFieldsSchema), + async (c) => { + const user = getAuthUser(c); + const { id } = c.req.valid("param"); + const { fieldIds } = c.req.valid("json"); + + // Verify note type exists and belongs to user + const noteType = await noteTypeRepo.findById(id, user.id); + if (!noteType) { + throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND"); + } + + const fields = await noteFieldTypeRepo.reorder(id, fieldIds); + + return c.json({ fields }, 200); + }, + ) + // Update field + .put( + "/:id/fields/:fieldId", + zValidator("param", noteTypeFieldIdParamSchema), + zValidator("json", updateNoteFieldTypeSchema), + async (c) => { + const user = getAuthUser(c); + const { id, fieldId } = c.req.valid("param"); + const data = c.req.valid("json"); + + // Verify note type exists and belongs to user + const noteType = await noteTypeRepo.findById(id, user.id); + if (!noteType) { + throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND"); + } + + const field = await noteFieldTypeRepo.update(fieldId, id, data); + if (!field) { + throw Errors.notFound("Field not found", "FIELD_NOT_FOUND"); + } + + return c.json({ field }, 200); + }, + ) + // Delete field + .delete( + "/:id/fields/:fieldId", + zValidator("param", noteTypeFieldIdParamSchema), + async (c) => { + const user = getAuthUser(c); + const { id, fieldId } = c.req.valid("param"); + + // Verify note type exists and belongs to user + const noteType = await noteTypeRepo.findById(id, user.id); + if (!noteType) { + throw Errors.notFound("Note type not found", "NOTE_TYPE_NOT_FOUND"); + } + + // Check if there are note field values referencing this field + const hasValues = await noteFieldTypeRepo.hasNoteFieldValues(fieldId); + if (hasValues) { + throw Errors.conflict( + "Cannot delete field with existing values", + "FIELD_HAS_VALUES", + ); + } + + const deleted = await noteFieldTypeRepo.softDelete(fieldId, id); + if (!deleted) { + throw Errors.notFound("Field not found", "FIELD_NOT_FOUND"); + } + + return c.json({ success: true }, 200); + }, + ) + ); +} + +export const noteTypes = createNoteTypesRouter({ + noteTypeRepo: noteTypeRepository, + noteFieldTypeRepo: noteFieldTypeRepository, +}); |
