From 5bb62819796bcd3b5e945662c23299eb8db71e34 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 31 Dec 2025 00:33:44 +0900 Subject: feat(db): add Note feature database schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add database tables and Zod validation schemas for Anki-compatible Note concept as outlined in the roadmap Phase 1: - note_types: defines note structure (templates, reversibility) - note_field_types: defines fields within a note type - notes: container for field values belonging to a deck - note_field_values: actual field content for notes - cards: add nullable note_id and is_reversed columns Includes migration file and comprehensive test coverage for all new Zod validation schemas. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/server/schemas/note.test.ts | 427 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 src/server/schemas/note.test.ts (limited to 'src/server/schemas/note.test.ts') diff --git a/src/server/schemas/note.test.ts b/src/server/schemas/note.test.ts new file mode 100644 index 0000000..0a9b84b --- /dev/null +++ b/src/server/schemas/note.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from "vitest"; +import { + createNoteFieldTypeSchema, + createNoteSchema, + createNoteTypeSchema, + noteFieldTypeSchema, + noteFieldValueSchema, + noteSchema, + noteTypeSchema, + updateNoteFieldTypeSchema, + updateNoteSchema, + updateNoteTypeSchema, +} from "./index"; + +describe("NoteType schemas", () => { + describe("noteTypeSchema", () => { + const validNoteType = { + id: "550e8400-e29b-41d4-a716-446655440000", + userId: "550e8400-e29b-41d4-a716-446655440001", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + }; + + it("should parse valid note type", () => { + const result = noteTypeSchema.safeParse(validNoteType); + expect(result.success).toBe(true); + }); + + it("should parse date strings", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.createdAt).toBeInstanceOf(Date); + expect(result.data.updatedAt).toBeInstanceOf(Date); + } + }); + + it("should reject invalid UUID for id", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + id: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + + it("should reject empty name", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + name: "", + }); + expect(result.success).toBe(false); + }); + + it("should reject name longer than 255 characters", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + name: "a".repeat(256), + }); + expect(result.success).toBe(false); + }); + + it("should reject empty frontTemplate", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + frontTemplate: "", + }); + expect(result.success).toBe(false); + }); + + it("should reject empty backTemplate", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + backTemplate: "", + }); + expect(result.success).toBe(false); + }); + + it("should reject negative syncVersion", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + syncVersion: -1, + }); + expect(result.success).toBe(false); + }); + + it("should accept deletedAt as date", () => { + const result = noteTypeSchema.safeParse({ + ...validNoteType, + deletedAt: new Date("2024-01-02"), + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.deletedAt).toBeInstanceOf(Date); + } + }); + }); + + describe("createNoteTypeSchema", () => { + it("should parse valid create input", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + }); + expect(result.success).toBe(true); + }); + + it("should default isReversible to false", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isReversible).toBe(false); + } + }); + + it("should reject missing name", () => { + const result = createNoteTypeSchema.safeParse({ + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }); + expect(result.success).toBe(false); + }); + + it("should reject missing frontTemplate", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + backTemplate: "{{Back}}", + }); + expect(result.success).toBe(false); + }); + + it("should reject missing backTemplate", () => { + const result = createNoteTypeSchema.safeParse({ + name: "Basic", + frontTemplate: "{{Front}}", + }); + expect(result.success).toBe(false); + }); + }); + + describe("updateNoteTypeSchema", () => { + it("should parse with all optional fields", () => { + const result = updateNoteTypeSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should parse with partial fields", () => { + const result = updateNoteTypeSchema.safeParse({ + name: "Updated Name", + }); + expect(result.success).toBe(true); + }); + + it("should parse with all fields", () => { + const result = updateNoteTypeSchema.safeParse({ + name: "Updated Name", + frontTemplate: "{{NewFront}}", + backTemplate: "{{NewBack}}", + isReversible: true, + }); + expect(result.success).toBe(true); + }); + + it("should reject empty name if provided", () => { + const result = updateNoteTypeSchema.safeParse({ + name: "", + }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("NoteFieldType schemas", () => { + describe("noteFieldTypeSchema", () => { + const validNoteFieldType = { + id: "550e8400-e29b-41d4-a716-446655440000", + noteTypeId: "550e8400-e29b-41d4-a716-446655440001", + name: "Front", + order: 0, + fieldType: "text" as const, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + }; + + it("should parse valid note field type", () => { + const result = noteFieldTypeSchema.safeParse(validNoteFieldType); + expect(result.success).toBe(true); + }); + + it("should reject invalid fieldType", () => { + const result = noteFieldTypeSchema.safeParse({ + ...validNoteFieldType, + fieldType: "invalid", + }); + expect(result.success).toBe(false); + }); + + it("should reject negative order", () => { + const result = noteFieldTypeSchema.safeParse({ + ...validNoteFieldType, + order: -1, + }); + expect(result.success).toBe(false); + }); + + it("should reject empty name", () => { + const result = noteFieldTypeSchema.safeParse({ + ...validNoteFieldType, + name: "", + }); + expect(result.success).toBe(false); + }); + }); + + describe("createNoteFieldTypeSchema", () => { + it("should parse valid create input", () => { + const result = createNoteFieldTypeSchema.safeParse({ + name: "Front", + order: 0, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.fieldType).toBe("text"); + } + }); + + it("should accept explicit fieldType", () => { + const result = createNoteFieldTypeSchema.safeParse({ + name: "Front", + order: 0, + fieldType: "text", + }); + expect(result.success).toBe(true); + }); + + it("should reject missing name", () => { + const result = createNoteFieldTypeSchema.safeParse({ + order: 0, + }); + expect(result.success).toBe(false); + }); + + it("should reject missing order", () => { + const result = createNoteFieldTypeSchema.safeParse({ + name: "Front", + }); + expect(result.success).toBe(false); + }); + }); + + describe("updateNoteFieldTypeSchema", () => { + it("should parse with all optional fields", () => { + const result = updateNoteFieldTypeSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should parse with name only", () => { + const result = updateNoteFieldTypeSchema.safeParse({ + name: "Updated Field", + }); + expect(result.success).toBe(true); + }); + + it("should parse with order only", () => { + const result = updateNoteFieldTypeSchema.safeParse({ + order: 1, + }); + expect(result.success).toBe(true); + }); + }); +}); + +describe("Note schemas", () => { + describe("noteSchema", () => { + const validNote = { + id: "550e8400-e29b-41d4-a716-446655440000", + deckId: "550e8400-e29b-41d4-a716-446655440001", + noteTypeId: "550e8400-e29b-41d4-a716-446655440002", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + }; + + it("should parse valid note", () => { + const result = noteSchema.safeParse(validNote); + expect(result.success).toBe(true); + }); + + it("should reject invalid deckId", () => { + const result = noteSchema.safeParse({ + ...validNote, + deckId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + + it("should reject invalid noteTypeId", () => { + const result = noteSchema.safeParse({ + ...validNote, + noteTypeId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + }); + + describe("createNoteSchema", () => { + it("should parse valid create input", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + fields: { + "550e8400-e29b-41d4-a716-446655440001": "Front content", + "550e8400-e29b-41d4-a716-446655440002": "Back content", + }, + }); + expect(result.success).toBe(true); + }); + + it("should accept empty fields", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + fields: {}, + }); + expect(result.success).toBe(true); + }); + + it("should reject missing noteTypeId", () => { + const result = createNoteSchema.safeParse({ + fields: {}, + }); + expect(result.success).toBe(false); + }); + + it("should reject missing fields", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(result.success).toBe(false); + }); + + it("should reject invalid UUID in fields key", () => { + const result = createNoteSchema.safeParse({ + noteTypeId: "550e8400-e29b-41d4-a716-446655440000", + fields: { + "not-a-uuid": "content", + }, + }); + expect(result.success).toBe(false); + }); + }); + + describe("updateNoteSchema", () => { + it("should parse valid update input", () => { + const result = updateNoteSchema.safeParse({ + fields: { + "550e8400-e29b-41d4-a716-446655440001": "Updated content", + }, + }); + expect(result.success).toBe(true); + }); + + it("should reject missing fields", () => { + const result = updateNoteSchema.safeParse({}); + expect(result.success).toBe(false); + }); + }); +}); + +describe("NoteFieldValue schemas", () => { + describe("noteFieldValueSchema", () => { + const validNoteFieldValue = { + id: "550e8400-e29b-41d4-a716-446655440000", + noteId: "550e8400-e29b-41d4-a716-446655440001", + noteFieldTypeId: "550e8400-e29b-41d4-a716-446655440002", + value: "Some content", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + syncVersion: 0, + }; + + it("should parse valid note field value", () => { + const result = noteFieldValueSchema.safeParse(validNoteFieldValue); + expect(result.success).toBe(true); + }); + + it("should accept empty value", () => { + const result = noteFieldValueSchema.safeParse({ + ...validNoteFieldValue, + value: "", + }); + expect(result.success).toBe(true); + }); + + it("should reject invalid noteId", () => { + const result = noteFieldValueSchema.safeParse({ + ...validNoteFieldValue, + noteId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + + it("should reject invalid noteFieldTypeId", () => { + const result = noteFieldValueSchema.safeParse({ + ...validNoteFieldValue, + noteFieldTypeId: "not-a-uuid", + }); + expect(result.success).toBe(false); + }); + }); +}); -- cgit v1.2.3-70-g09d2