diff options
Diffstat (limited to 'src/server/repositories')
| -rw-r--r-- | src/server/repositories/card.test.ts | 138 | ||||
| -rw-r--r-- | src/server/repositories/card.ts | 20 | ||||
| -rw-r--r-- | src/server/repositories/deck.test.ts | 212 | ||||
| -rw-r--r-- | src/server/repositories/deck.ts | 3 | ||||
| -rw-r--r-- | src/server/repositories/note.test.ts | 198 | ||||
| -rw-r--r-- | src/server/repositories/note.ts | 134 | ||||
| -rw-r--r-- | src/server/repositories/noteType.test.ts | 81 | ||||
| -rw-r--r-- | src/server/repositories/noteType.ts | 3 | ||||
| -rw-r--r-- | src/server/repositories/sync.test.ts | 397 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 12 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 20 |
11 files changed, 1207 insertions, 11 deletions
diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts index 4263dad..b492fd7 100644 --- a/src/server/repositories/card.test.ts +++ b/src/server/repositories/card.test.ts @@ -111,6 +111,7 @@ function createMockCardRepo(): CardRepository { softDelete: vi.fn(), softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), + countDueCards: vi.fn(), findDueCardsWithNoteData: vi.fn(), findDueCardsForStudy: vi.fn(), updateFSRSFields: vi.fn(), @@ -504,3 +505,140 @@ describe("Card deletion behavior", () => { }); }); }); + +describe("findByDeckId ordering", () => { + it("returns cards ordered by createdAt", async () => { + const repo = createMockCardRepo(); + + const oldCard = createMockCard({ + id: "card-old", + createdAt: new Date("2024-01-01"), + }); + const newCard = createMockCard({ + id: "card-new", + createdAt: new Date("2024-06-01"), + }); + + vi.mocked(repo.findByDeckId).mockResolvedValue([oldCard, newCard]); + + const results = await repo.findByDeckId("deck-123"); + + expect(results).toHaveLength(2); + expect(results[0]?.id).toBe("card-old"); + expect(results[1]?.id).toBe("card-new"); + expect(results[0]?.createdAt.getTime()).toBeLessThan( + results[1]?.createdAt.getTime() ?? 0, + ); + }); + + it("returns empty array when deck has no cards", async () => { + const repo = createMockCardRepo(); + + vi.mocked(repo.findByDeckId).mockResolvedValue([]); + + const results = await repo.findByDeckId("deck-with-no-cards"); + expect(results).toHaveLength(0); + }); + + it("maintains consistent ordering across multiple calls", async () => { + const repo = createMockCardRepo(); + + const card1 = createMockCard({ + id: "card-1", + createdAt: new Date("2024-01-01"), + }); + const card2 = createMockCard({ + id: "card-2", + createdAt: new Date("2024-02-01"), + }); + const card3 = createMockCard({ + id: "card-3", + createdAt: new Date("2024-03-01"), + }); + + vi.mocked(repo.findByDeckId).mockResolvedValue([card1, card2, card3]); + + const results1 = await repo.findByDeckId("deck-123"); + const results2 = await repo.findByDeckId("deck-123"); + + expect(results1.map((c) => c.id)).toEqual(results2.map((c) => c.id)); + expect(results1.map((c) => c.id)).toEqual(["card-1", "card-2", "card-3"]); + }); +}); + +describe("findByNoteId ordering", () => { + it("returns cards ordered by isReversed (normal card first)", async () => { + const repo = createMockCardRepo(); + + const normalCard = createMockCard({ + id: "card-normal", + noteId: "note-123", + isReversed: false, + }); + const reversedCard = createMockCard({ + id: "card-reversed", + noteId: "note-123", + isReversed: true, + }); + + // Mock returns cards in isReversed order (false first, true second) + vi.mocked(repo.findByNoteId).mockResolvedValue([normalCard, reversedCard]); + + const results = await repo.findByNoteId("note-123"); + + expect(results).toHaveLength(2); + expect(results[0]?.id).toBe("card-normal"); + expect(results[0]?.isReversed).toBe(false); + expect(results[1]?.id).toBe("card-reversed"); + expect(results[1]?.isReversed).toBe(true); + }); + + it("returns single card for non-reversible note type", async () => { + const repo = createMockCardRepo(); + + const normalCard = createMockCard({ + id: "card-only", + noteId: "note-123", + isReversed: false, + }); + + vi.mocked(repo.findByNoteId).mockResolvedValue([normalCard]); + + const results = await repo.findByNoteId("note-123"); + + expect(results).toHaveLength(1); + expect(results[0]?.isReversed).toBe(false); + }); + + it("returns empty array when note has no cards", async () => { + const repo = createMockCardRepo(); + + vi.mocked(repo.findByNoteId).mockResolvedValue([]); + + const results = await repo.findByNoteId("note-without-cards"); + expect(results).toHaveLength(0); + }); + + it("maintains consistent ordering across multiple calls", async () => { + const repo = createMockCardRepo(); + + const normalCard = createMockCard({ + id: "card-normal", + noteId: "note-123", + isReversed: false, + }); + const reversedCard = createMockCard({ + id: "card-reversed", + noteId: "note-123", + isReversed: true, + }); + + vi.mocked(repo.findByNoteId).mockResolvedValue([normalCard, reversedCard]); + + const results1 = await repo.findByNoteId("note-123"); + const results2 = await repo.findByNoteId("note-123"); + + expect(results1.map((c) => c.id)).toEqual(results2.map((c) => c.id)); + expect(results1.map((c) => c.isReversed)).toEqual([false, true]); + }); +}); diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts index 761b317..ac03bc6 100644 --- a/src/server/repositories/card.ts +++ b/src/server/repositories/card.ts @@ -20,7 +20,8 @@ export const cardRepository: CardRepository = { const result = await db .select() .from(cards) - .where(and(eq(cards.deckId, deckId), isNull(cards.deletedAt))); + .where(and(eq(cards.deckId, deckId), isNull(cards.deletedAt))) + .orderBy(cards.createdAt); return result; }, @@ -73,7 +74,8 @@ export const cardRepository: CardRepository = { const result = await db .select() .from(cards) - .where(and(eq(cards.noteId, noteId), isNull(cards.deletedAt))); + .where(and(eq(cards.noteId, noteId), isNull(cards.deletedAt))) + .orderBy(cards.isReversed); return result; }, @@ -202,6 +204,20 @@ export const cardRepository: CardRepository = { return result; }, + async countDueCards(deckId: string, now: Date): Promise<number> { + const result = await db + .select({ count: sql<number>`count(*)::int` }) + .from(cards) + .where( + and( + eq(cards.deckId, deckId), + isNull(cards.deletedAt), + lte(cards.due, now), + ), + ); + return result[0]?.count ?? 0; + }, + async findDueCardsWithNoteData( deckId: string, now: Date, diff --git a/src/server/repositories/deck.test.ts b/src/server/repositories/deck.test.ts new file mode 100644 index 0000000..945f844 --- /dev/null +++ b/src/server/repositories/deck.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Deck, DeckRepository } from "./types.js"; + +function createMockDeck(overrides: Partial<Deck> = {}): Deck { + return { + id: "deck-uuid-123", + userId: "user-uuid-123", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + ...overrides, + }; +} + +function createMockDeckRepo(): DeckRepository { + return { + findByUserId: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + }; +} + +describe("DeckRepository mock factory", () => { + describe("createMockDeck", () => { + it("creates a valid Deck with defaults", () => { + const deck = createMockDeck(); + + expect(deck.id).toBe("deck-uuid-123"); + expect(deck.userId).toBe("user-uuid-123"); + expect(deck.name).toBe("Test Deck"); + expect(deck.description).toBeNull(); + expect(deck.newCardsPerDay).toBe(20); + expect(deck.deletedAt).toBeNull(); + expect(deck.syncVersion).toBe(0); + }); + + it("allows overriding properties", () => { + const deck = createMockDeck({ + id: "custom-id", + name: "Custom Deck", + description: "A description", + newCardsPerDay: 50, + }); + + expect(deck.id).toBe("custom-id"); + expect(deck.name).toBe("Custom Deck"); + expect(deck.description).toBe("A description"); + expect(deck.newCardsPerDay).toBe(50); + }); + }); + + describe("createMockDeckRepo", () => { + it("creates a repository with all required methods", () => { + const repo = createMockDeckRepo(); + + expect(repo.findByUserId).toBeDefined(); + expect(repo.findById).toBeDefined(); + expect(repo.create).toBeDefined(); + expect(repo.update).toBeDefined(); + expect(repo.softDelete).toBeDefined(); + }); + + it("methods are mockable for findByUserId", async () => { + const repo = createMockDeckRepo(); + const mockDecks = [ + createMockDeck({ id: "deck-1" }), + createMockDeck({ id: "deck-2" }), + ]; + + vi.mocked(repo.findByUserId).mockResolvedValue(mockDecks); + + const results = await repo.findByUserId("user-123"); + expect(results).toHaveLength(2); + expect(repo.findByUserId).toHaveBeenCalledWith("user-123"); + }); + + it("methods are mockable for findById", async () => { + const repo = createMockDeckRepo(); + const mockDeck = createMockDeck(); + + vi.mocked(repo.findById).mockResolvedValue(mockDeck); + + const found = await repo.findById("deck-id", "user-id"); + expect(found).toEqual(mockDeck); + expect(repo.findById).toHaveBeenCalledWith("deck-id", "user-id"); + }); + + it("returns undefined when deck not found", async () => { + const repo = createMockDeckRepo(); + + vi.mocked(repo.findById).mockResolvedValue(undefined); + + expect(await repo.findById("nonexistent", "user-id")).toBeUndefined(); + }); + + it("returns false when soft delete fails", async () => { + const repo = createMockDeckRepo(); + + vi.mocked(repo.softDelete).mockResolvedValue(false); + + expect(await repo.softDelete("nonexistent", "user-id")).toBe(false); + }); + }); +}); + +describe("Deck interface contracts", () => { + it("Deck has required sync fields", () => { + const deck = createMockDeck(); + + expect(deck).toHaveProperty("syncVersion"); + expect(deck).toHaveProperty("createdAt"); + expect(deck).toHaveProperty("updatedAt"); + expect(deck).toHaveProperty("deletedAt"); + }); + + it("Deck has required user association", () => { + const deck = createMockDeck(); + + expect(deck).toHaveProperty("userId"); + }); + + it("Deck has required configuration fields", () => { + const deck = createMockDeck(); + + expect(deck).toHaveProperty("name"); + expect(deck).toHaveProperty("description"); + expect(deck).toHaveProperty("newCardsPerDay"); + }); +}); + +describe("findByUserId ordering", () => { + it("returns decks ordered by createdAt", async () => { + const repo = createMockDeckRepo(); + + // Simulate decks created at different times + const oldDeck = createMockDeck({ + id: "deck-old", + name: "Old Deck", + createdAt: new Date("2024-01-01"), + }); + const newDeck = createMockDeck({ + id: "deck-new", + name: "New Deck", + createdAt: new Date("2024-06-01"), + }); + + // Mock returns decks in createdAt order (oldest first) + vi.mocked(repo.findByUserId).mockResolvedValue([oldDeck, newDeck]); + + const results = await repo.findByUserId("user-123"); + + expect(results).toHaveLength(2); + expect(results[0]?.id).toBe("deck-old"); + expect(results[1]?.id).toBe("deck-new"); + // Verify the order is by createdAt + expect(results[0]?.createdAt.getTime()).toBeLessThan( + results[1]?.createdAt.getTime() ?? 0, + ); + }); + + it("returns empty array when user has no decks", async () => { + const repo = createMockDeckRepo(); + + vi.mocked(repo.findByUserId).mockResolvedValue([]); + + const results = await repo.findByUserId("user-with-no-decks"); + expect(results).toHaveLength(0); + }); + + it("returns single deck when user has one deck", async () => { + const repo = createMockDeckRepo(); + const singleDeck = createMockDeck({ id: "only-deck" }); + + vi.mocked(repo.findByUserId).mockResolvedValue([singleDeck]); + + const results = await repo.findByUserId("user-123"); + expect(results).toHaveLength(1); + expect(results[0]?.id).toBe("only-deck"); + }); + + it("maintains consistent ordering across multiple calls", async () => { + const repo = createMockDeckRepo(); + + const deck1 = createMockDeck({ + id: "deck-1", + createdAt: new Date("2024-01-01"), + }); + const deck2 = createMockDeck({ + id: "deck-2", + createdAt: new Date("2024-02-01"), + }); + const deck3 = createMockDeck({ + id: "deck-3", + createdAt: new Date("2024-03-01"), + }); + + vi.mocked(repo.findByUserId).mockResolvedValue([deck1, deck2, deck3]); + + const results1 = await repo.findByUserId("user-123"); + const results2 = await repo.findByUserId("user-123"); + + // Order should be consistent + expect(results1.map((d) => d.id)).toEqual(results2.map((d) => d.id)); + expect(results1.map((d) => d.id)).toEqual(["deck-1", "deck-2", "deck-3"]); + }); +}); diff --git a/src/server/repositories/deck.ts b/src/server/repositories/deck.ts index 77985a7..647c5cb 100644 --- a/src/server/repositories/deck.ts +++ b/src/server/repositories/deck.ts @@ -8,7 +8,8 @@ export const deckRepository: DeckRepository = { const result = await db .select() .from(decks) - .where(and(eq(decks.userId, userId), isNull(decks.deletedAt))); + .where(and(eq(decks.userId, userId), isNull(decks.deletedAt))) + .orderBy(decks.createdAt); return result; }, diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts index 790ed7e..2c4b900 100644 --- a/src/server/repositories/note.test.ts +++ b/src/server/repositories/note.test.ts @@ -109,6 +109,7 @@ function createMockNoteRepo(): NoteRepository { create: vi.fn(), update: vi.fn(), softDelete: vi.fn(), + createMany: vi.fn(), }; } @@ -455,3 +456,200 @@ describe("Card generation from Note", () => { expect(result.cards[1]?.back).toBe("Question"); }); }); + +describe("findByDeckId ordering", () => { + it("returns notes ordered by createdAt", async () => { + const repo = createMockNoteRepo(); + + const oldNote = createMockNote({ + id: "note-old", + createdAt: new Date("2024-01-01"), + }); + const newNote = createMockNote({ + id: "note-new", + createdAt: new Date("2024-06-01"), + }); + + vi.mocked(repo.findByDeckId).mockResolvedValue([oldNote, newNote]); + + const results = await repo.findByDeckId("deck-123"); + + expect(results).toHaveLength(2); + expect(results[0]?.id).toBe("note-old"); + expect(results[1]?.id).toBe("note-new"); + expect(results[0]?.createdAt.getTime()).toBeLessThan( + results[1]?.createdAt.getTime() ?? 0, + ); + }); + + it("returns empty array when deck has no notes", async () => { + const repo = createMockNoteRepo(); + + vi.mocked(repo.findByDeckId).mockResolvedValue([]); + + const results = await repo.findByDeckId("deck-with-no-notes"); + expect(results).toHaveLength(0); + }); + + it("maintains consistent ordering across multiple calls", async () => { + const repo = createMockNoteRepo(); + + const note1 = createMockNote({ + id: "note-1", + createdAt: new Date("2024-01-01"), + }); + const note2 = createMockNote({ + id: "note-2", + createdAt: new Date("2024-02-01"), + }); + const note3 = createMockNote({ + id: "note-3", + createdAt: new Date("2024-03-01"), + }); + + vi.mocked(repo.findByDeckId).mockResolvedValue([note1, note2, note3]); + + const results1 = await repo.findByDeckId("deck-123"); + const results2 = await repo.findByDeckId("deck-123"); + + expect(results1.map((n) => n.id)).toEqual(results2.map((n) => n.id)); + expect(results1.map((n) => n.id)).toEqual(["note-1", "note-2", "note-3"]); + }); +}); + +describe("findByIdWithFieldValues field values ordering", () => { + it("returns field values ordered by noteFieldTypeId", async () => { + const repo = createMockNoteRepo(); + + const fieldValueA = createMockNoteFieldValue({ + id: "fv-1", + noteFieldTypeId: "field-type-aaa", + value: "Value A", + }); + const fieldValueB = createMockNoteFieldValue({ + id: "fv-2", + noteFieldTypeId: "field-type-bbb", + value: "Value B", + }); + + const noteWithFields = createMockNoteWithFieldValues({ + fieldValues: [fieldValueA, fieldValueB], + }); + + vi.mocked(repo.findByIdWithFieldValues).mockResolvedValue(noteWithFields); + + const result = await repo.findByIdWithFieldValues("note-id", "deck-id"); + + expect(result?.fieldValues).toHaveLength(2); + expect(result?.fieldValues[0]?.noteFieldTypeId).toBe("field-type-aaa"); + expect(result?.fieldValues[1]?.noteFieldTypeId).toBe("field-type-bbb"); + }); + + it("maintains consistent field value ordering across multiple calls", async () => { + const repo = createMockNoteRepo(); + + const fieldValues = [ + createMockNoteFieldValue({ + id: "fv-1", + noteFieldTypeId: "ft-001", + value: "First", + }), + createMockNoteFieldValue({ + id: "fv-2", + noteFieldTypeId: "ft-002", + value: "Second", + }), + createMockNoteFieldValue({ + id: "fv-3", + noteFieldTypeId: "ft-003", + value: "Third", + }), + ]; + + const noteWithFields = createMockNoteWithFieldValues({ fieldValues }); + + vi.mocked(repo.findByIdWithFieldValues).mockResolvedValue(noteWithFields); + + const results1 = await repo.findByIdWithFieldValues("note-id", "deck-id"); + const results2 = await repo.findByIdWithFieldValues("note-id", "deck-id"); + + expect(results1?.fieldValues.map((fv) => fv.noteFieldTypeId)).toEqual( + results2?.fieldValues.map((fv) => fv.noteFieldTypeId), + ); + expect(results1?.fieldValues.map((fv) => fv.noteFieldTypeId)).toEqual([ + "ft-001", + "ft-002", + "ft-003", + ]); + }); +}); + +describe("update field values ordering", () => { + it("returns field values ordered by noteFieldTypeId after update", async () => { + const repo = createMockNoteRepo(); + + const fieldValueA = createMockNoteFieldValue({ + id: "fv-1", + noteFieldTypeId: "field-type-aaa", + value: "Updated A", + }); + const fieldValueB = createMockNoteFieldValue({ + id: "fv-2", + noteFieldTypeId: "field-type-bbb", + value: "Updated B", + }); + + const updatedNote = createMockNoteWithFieldValues({ + fieldValues: [fieldValueA, fieldValueB], + }); + + vi.mocked(repo.update).mockResolvedValue(updatedNote); + + const result = await repo.update("note-id", "deck-id", { + "field-type-aaa": "Updated A", + "field-type-bbb": "Updated B", + }); + + expect(result?.fieldValues).toHaveLength(2); + expect(result?.fieldValues[0]?.noteFieldTypeId).toBe("field-type-aaa"); + expect(result?.fieldValues[1]?.noteFieldTypeId).toBe("field-type-bbb"); + }); + + it("maintains consistent field value ordering after update", async () => { + const repo = createMockNoteRepo(); + + const fieldValues = [ + createMockNoteFieldValue({ + id: "fv-1", + noteFieldTypeId: "ft-001", + value: "Updated First", + }), + createMockNoteFieldValue({ + id: "fv-2", + noteFieldTypeId: "ft-002", + value: "Updated Second", + }), + createMockNoteFieldValue({ + id: "fv-3", + noteFieldTypeId: "ft-003", + value: "Updated Third", + }), + ]; + + const updatedNote = createMockNoteWithFieldValues({ fieldValues }); + + vi.mocked(repo.update).mockResolvedValue(updatedNote); + + const result = await repo.update("note-id", "deck-id", { + "ft-001": "Updated First", + "ft-002": "Updated Second", + "ft-003": "Updated Third", + }); + + expect(result?.fieldValues.map((fv) => fv.noteFieldTypeId)).toEqual([ + "ft-001", + "ft-002", + "ft-003", + ]); + }); +}); diff --git a/src/server/repositories/note.ts b/src/server/repositories/note.ts index 52cbf9b..6f607cf 100644 --- a/src/server/repositories/note.ts +++ b/src/server/repositories/note.ts @@ -9,6 +9,8 @@ import { noteTypes, } from "../db/schema.js"; import type { + BulkCreateNoteInput, + BulkCreateNoteResult, Card, CreateNoteResult, Note, @@ -22,7 +24,8 @@ export const noteRepository: NoteRepository = { const result = await db .select() .from(notes) - .where(and(eq(notes.deckId, deckId), isNull(notes.deletedAt))); + .where(and(eq(notes.deckId, deckId), isNull(notes.deletedAt))) + .orderBy(notes.createdAt); return result; }, @@ -52,7 +55,8 @@ export const noteRepository: NoteRepository = { const fieldValuesResult = await db .select() .from(noteFieldValues) - .where(eq(noteFieldValues.noteId, id)); + .where(eq(noteFieldValues.noteId, id)) + .orderBy(noteFieldValues.noteFieldTypeId); return { ...note, @@ -219,7 +223,8 @@ export const noteRepository: NoteRepository = { const allFieldValues = await db .select() .from(noteFieldValues) - .where(eq(noteFieldValues.noteId, id)); + .where(eq(noteFieldValues.noteId, id)) + .orderBy(noteFieldValues.noteFieldTypeId); return { ...updatedNote, @@ -262,6 +267,129 @@ export const noteRepository: NoteRepository = { return result.length > 0; }, + + async createMany( + deckId: string, + notesInput: BulkCreateNoteInput[], + ): Promise<BulkCreateNoteResult> { + const failed: { index: number; error: string }[] = []; + let created = 0; + + // Pre-fetch all note types and their field types for validation + const noteTypeCache = new Map< + string, + { + noteType: { + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + }; + fieldTypes: { id: string; name: string }[]; + } + >(); + + for (let i = 0; i < notesInput.length; i++) { + const input = notesInput[i]; + if (!input) continue; + + try { + // Get note type from cache or fetch + let cached = noteTypeCache.get(input.noteTypeId); + if (!cached) { + const noteType = await db + .select() + .from(noteTypes) + .where( + and( + eq(noteTypes.id, input.noteTypeId), + isNull(noteTypes.deletedAt), + ), + ); + + if (!noteType[0]) { + failed.push({ index: i, error: "Note type not found" }); + continue; + } + + const fieldTypes = await db + .select() + .from(noteFieldTypes) + .where( + and( + eq(noteFieldTypes.noteTypeId, input.noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ) + .orderBy(noteFieldTypes.order); + + cached = { noteType: noteType[0], fieldTypes }; + noteTypeCache.set(input.noteTypeId, cached); + } + + // Create note + const [note] = await db + .insert(notes) + .values({ + deckId, + noteTypeId: input.noteTypeId, + }) + .returning(); + + if (!note) { + failed.push({ index: i, error: "Failed to create note" }); + continue; + } + + // Create field values + const fieldValuesResult: NoteFieldValue[] = []; + for (const fieldType of cached.fieldTypes) { + const value = input.fields[fieldType.id] ?? ""; + const [fieldValue] = await db + .insert(noteFieldValues) + .values({ + noteId: note.id, + noteFieldTypeId: fieldType.id, + value, + }) + .returning(); + if (fieldValue) { + fieldValuesResult.push(fieldValue); + } + } + + // Create normal card + await createCardForNote( + deckId, + note.id, + cached.noteType, + fieldValuesResult, + cached.fieldTypes, + false, + ); + + // Create reversed card if reversible + if (cached.noteType.isReversible) { + await createCardForNote( + deckId, + note.id, + cached.noteType, + fieldValuesResult, + cached.fieldTypes, + true, + ); + } + + created++; + } catch (error) { + failed.push({ + index: i, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return { created, failed }; + }, }; async function createCardForNote( diff --git a/src/server/repositories/noteType.test.ts b/src/server/repositories/noteType.test.ts index 22e8839..fdb9d5c 100644 --- a/src/server/repositories/noteType.test.ts +++ b/src/server/repositories/noteType.test.ts @@ -356,3 +356,84 @@ describe("NoteFieldType deletion constraints", () => { }); }); }); + +describe("findByUserId ordering", () => { + it("returns note types ordered by createdAt", async () => { + const repo = createMockNoteTypeRepo(); + + const oldNoteType = createMockNoteType({ + id: "note-type-old", + name: "Old Type", + createdAt: new Date("2024-01-01"), + }); + const newNoteType = createMockNoteType({ + id: "note-type-new", + name: "New Type", + createdAt: new Date("2024-06-01"), + }); + + vi.mocked(repo.findByUserId).mockResolvedValue([oldNoteType, newNoteType]); + + const results = await repo.findByUserId("user-123"); + + expect(results).toHaveLength(2); + expect(results[0]?.id).toBe("note-type-old"); + expect(results[1]?.id).toBe("note-type-new"); + expect(results[0]?.createdAt.getTime()).toBeLessThan( + results[1]?.createdAt.getTime() ?? 0, + ); + }); + + it("returns empty array when user has no note types", async () => { + const repo = createMockNoteTypeRepo(); + + vi.mocked(repo.findByUserId).mockResolvedValue([]); + + const results = await repo.findByUserId("user-with-no-note-types"); + expect(results).toHaveLength(0); + }); + + it("returns single note type when user has one", async () => { + const repo = createMockNoteTypeRepo(); + const singleNoteType = createMockNoteType({ id: "only-note-type" }); + + vi.mocked(repo.findByUserId).mockResolvedValue([singleNoteType]); + + const results = await repo.findByUserId("user-123"); + expect(results).toHaveLength(1); + expect(results[0]?.id).toBe("only-note-type"); + }); + + it("maintains consistent ordering across multiple calls", async () => { + const repo = createMockNoteTypeRepo(); + + const noteType1 = createMockNoteType({ + id: "note-type-1", + createdAt: new Date("2024-01-01"), + }); + const noteType2 = createMockNoteType({ + id: "note-type-2", + createdAt: new Date("2024-02-01"), + }); + const noteType3 = createMockNoteType({ + id: "note-type-3", + createdAt: new Date("2024-03-01"), + }); + + vi.mocked(repo.findByUserId).mockResolvedValue([ + noteType1, + noteType2, + noteType3, + ]); + + const results1 = await repo.findByUserId("user-123"); + const results2 = await repo.findByUserId("user-123"); + + expect(results1.map((nt) => nt.id)).toEqual(results2.map((nt) => nt.id)); + expect(results1.map((nt) => nt.id)).toEqual([ + "note-type-1", + "note-type-2", + "note-type-3", + ]); + }); +}); diff --git a/src/server/repositories/noteType.ts b/src/server/repositories/noteType.ts index 25aa8ff..06c8834 100644 --- a/src/server/repositories/noteType.ts +++ b/src/server/repositories/noteType.ts @@ -14,7 +14,8 @@ export const noteTypeRepository: NoteTypeRepository = { const result = await db .select() .from(noteTypes) - .where(and(eq(noteTypes.userId, userId), isNull(noteTypes.deletedAt))); + .where(and(eq(noteTypes.userId, userId), isNull(noteTypes.deletedAt))) + .orderBy(noteTypes.createdAt); return result; }, diff --git a/src/server/repositories/sync.test.ts b/src/server/repositories/sync.test.ts new file mode 100644 index 0000000..ce59cb5 --- /dev/null +++ b/src/server/repositories/sync.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SyncPullResult, SyncPushResult, SyncRepository } from "./sync.js"; +import type { + Card, + Deck, + Note, + NoteFieldType, + NoteFieldValue, + NoteType, + ReviewLog, +} from "./types.js"; + +function createMockDeck(overrides: Partial<Deck> = {}): Deck { + return { + id: "deck-uuid-123", + userId: "user-uuid-123", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 1, + ...overrides, + }; +} + +function createMockCard(overrides: Partial<Card> = {}): Card { + return { + id: "card-uuid-123", + deckId: "deck-uuid-123", + noteId: "note-uuid-123", + isReversed: false, + front: "Front text", + back: "Back text", + state: 0, + 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: 1, + ...overrides, + }; +} + +function createMockNote(overrides: Partial<Note> = {}): Note { + return { + id: "note-uuid-123", + deckId: "deck-uuid-123", + noteTypeId: "note-type-uuid-123", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 1, + ...overrides, + }; +} + +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: 1, + ...overrides, + }; +} + +function createMockNoteFieldType( + overrides: Partial<NoteFieldType> = {}, +): NoteFieldType { + return { + id: "field-type-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: 1, + ...overrides, + }; +} + +function createMockNoteFieldValue( + overrides: Partial<NoteFieldValue> = {}, +): NoteFieldValue { + return { + id: "field-value-uuid-123", + noteId: "note-uuid-123", + noteFieldTypeId: "field-type-uuid-123", + value: "Test value", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + syncVersion: 1, + ...overrides, + }; +} + +function createMockReviewLog(overrides: Partial<ReviewLog> = {}): ReviewLog { + return { + id: "review-log-uuid-123", + cardId: "card-uuid-123", + userId: "user-uuid-123", + rating: 3, + state: 2, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date("2024-01-01"), + durationMs: 5000, + syncVersion: 1, + ...overrides, + }; +} + +function createMockSyncRepo(): SyncRepository { + return { + pushChanges: vi.fn(), + pullChanges: vi.fn(), + }; +} + +describe("SyncRepository mock factory", () => { + describe("createMockSyncRepo", () => { + it("creates a repository with all required methods", () => { + const repo = createMockSyncRepo(); + + expect(repo.pushChanges).toBeDefined(); + expect(repo.pullChanges).toBeDefined(); + }); + + it("methods are mockable for pushChanges", async () => { + const repo = createMockSyncRepo(); + const mockResult: SyncPushResult = { + decks: [{ id: "deck-1", syncVersion: 1 }], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + crdtChanges: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, + }; + + vi.mocked(repo.pushChanges).mockResolvedValue(mockResult); + + const result = await repo.pushChanges("user-123", { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }); + + expect(result.decks).toHaveLength(1); + expect(repo.pushChanges).toHaveBeenCalledWith( + "user-123", + expect.any(Object), + ); + }); + + it("methods are mockable for pullChanges", async () => { + const repo = createMockSyncRepo(); + const mockResult: SyncPullResult = { + decks: [createMockDeck()], + cards: [createMockCard()], + reviewLogs: [createMockReviewLog()], + noteTypes: [createMockNoteType()], + noteFieldTypes: [createMockNoteFieldType()], + notes: [createMockNote()], + noteFieldValues: [createMockNoteFieldValue()], + crdtChanges: [], + currentSyncVersion: 5, + }; + + vi.mocked(repo.pullChanges).mockResolvedValue(mockResult); + + const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 }); + + expect(result.decks).toHaveLength(1); + expect(result.cards).toHaveLength(1); + expect(result.currentSyncVersion).toBe(5); + expect(repo.pullChanges).toHaveBeenCalledWith("user-123", { + lastSyncVersion: 0, + }); + }); + }); +}); + +describe("SyncPullResult ordering", () => { + describe("pullChanges returns entities ordered by id", () => { + it("returns cards ordered by id", async () => { + const repo = createMockSyncRepo(); + + const cardA = createMockCard({ id: "card-aaa" }); + const cardB = createMockCard({ id: "card-bbb" }); + const cardC = createMockCard({ id: "card-ccc" }); + + const mockResult: SyncPullResult = { + decks: [], + cards: [cardA, cardB, cardC], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + crdtChanges: [], + currentSyncVersion: 1, + }; + + vi.mocked(repo.pullChanges).mockResolvedValue(mockResult); + + const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 }); + + expect(result.cards).toHaveLength(3); + expect(result.cards[0]?.id).toBe("card-aaa"); + expect(result.cards[1]?.id).toBe("card-bbb"); + expect(result.cards[2]?.id).toBe("card-ccc"); + }); + + it("returns notes ordered by id", async () => { + const repo = createMockSyncRepo(); + + const noteA = createMockNote({ id: "note-aaa" }); + const noteB = createMockNote({ id: "note-bbb" }); + const noteC = createMockNote({ id: "note-ccc" }); + + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [noteA, noteB, noteC], + noteFieldValues: [], + crdtChanges: [], + currentSyncVersion: 1, + }; + + vi.mocked(repo.pullChanges).mockResolvedValue(mockResult); + + const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 }); + + expect(result.notes).toHaveLength(3); + expect(result.notes[0]?.id).toBe("note-aaa"); + expect(result.notes[1]?.id).toBe("note-bbb"); + expect(result.notes[2]?.id).toBe("note-ccc"); + }); + + it("returns noteFieldTypes ordered by id", async () => { + const repo = createMockSyncRepo(); + + const fieldTypeA = createMockNoteFieldType({ id: "ft-aaa" }); + const fieldTypeB = createMockNoteFieldType({ id: "ft-bbb" }); + const fieldTypeC = createMockNoteFieldType({ id: "ft-ccc" }); + + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [fieldTypeA, fieldTypeB, fieldTypeC], + notes: [], + noteFieldValues: [], + crdtChanges: [], + currentSyncVersion: 1, + }; + + vi.mocked(repo.pullChanges).mockResolvedValue(mockResult); + + const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 }); + + expect(result.noteFieldTypes).toHaveLength(3); + expect(result.noteFieldTypes[0]?.id).toBe("ft-aaa"); + expect(result.noteFieldTypes[1]?.id).toBe("ft-bbb"); + expect(result.noteFieldTypes[2]?.id).toBe("ft-ccc"); + }); + + it("returns noteFieldValues ordered by id", async () => { + const repo = createMockSyncRepo(); + + const fieldValueA = createMockNoteFieldValue({ id: "fv-aaa" }); + const fieldValueB = createMockNoteFieldValue({ id: "fv-bbb" }); + const fieldValueC = createMockNoteFieldValue({ id: "fv-ccc" }); + + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [fieldValueA, fieldValueB, fieldValueC], + crdtChanges: [], + currentSyncVersion: 1, + }; + + vi.mocked(repo.pullChanges).mockResolvedValue(mockResult); + + const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 }); + + expect(result.noteFieldValues).toHaveLength(3); + expect(result.noteFieldValues[0]?.id).toBe("fv-aaa"); + expect(result.noteFieldValues[1]?.id).toBe("fv-bbb"); + expect(result.noteFieldValues[2]?.id).toBe("fv-ccc"); + }); + + it("maintains consistent ordering across multiple calls", async () => { + const repo = createMockSyncRepo(); + + const cards = [ + createMockCard({ id: "card-001" }), + createMockCard({ id: "card-002" }), + createMockCard({ id: "card-003" }), + ]; + + const mockResult: SyncPullResult = { + decks: [], + cards, + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + crdtChanges: [], + currentSyncVersion: 1, + }; + + vi.mocked(repo.pullChanges).mockResolvedValue(mockResult); + + const result1 = await repo.pullChanges("user-123", { + lastSyncVersion: 0, + }); + const result2 = await repo.pullChanges("user-123", { + lastSyncVersion: 0, + }); + + expect(result1.cards.map((c) => c.id)).toEqual( + result2.cards.map((c) => c.id), + ); + expect(result1.cards.map((c) => c.id)).toEqual([ + "card-001", + "card-002", + "card-003", + ]); + }); + + it("returns empty arrays when no entities to pull", async () => { + const repo = createMockSyncRepo(); + + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + crdtChanges: [], + currentSyncVersion: 0, + }; + + vi.mocked(repo.pullChanges).mockResolvedValue(mockResult); + + const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 }); + + expect(result.cards).toHaveLength(0); + expect(result.notes).toHaveLength(0); + expect(result.noteFieldTypes).toHaveLength(0); + expect(result.noteFieldValues).toHaveLength(0); + }); + }); +}); diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts index 188bd1b..ca4c208 100644 --- a/src/server/repositories/sync.ts +++ b/src/server/repositories/sync.ts @@ -896,7 +896,8 @@ export const syncRepository: SyncRepository = { const cardResults = await db .select() .from(cards) - .where(gt(cards.syncVersion, lastSyncVersion)); + .where(gt(cards.syncVersion, lastSyncVersion)) + .orderBy(cards.id); // Filter cards that belong to user's decks pulledCards = cardResults.filter((c) => deckIdList.includes(c.deckId)); @@ -938,7 +939,8 @@ export const syncRepository: SyncRepository = { const fieldTypeResults = await db .select() .from(noteFieldTypes) - .where(gt(noteFieldTypes.syncVersion, lastSyncVersion)); + .where(gt(noteFieldTypes.syncVersion, lastSyncVersion)) + .orderBy(noteFieldTypes.id); pulledNoteFieldTypes = fieldTypeResults.filter((ft) => noteTypeIdList.includes(ft.noteTypeId), @@ -951,7 +953,8 @@ export const syncRepository: SyncRepository = { const noteResults = await db .select() .from(notes) - .where(gt(notes.syncVersion, lastSyncVersion)); + .where(gt(notes.syncVersion, lastSyncVersion)) + .orderBy(notes.id); pulledNotes = noteResults.filter((n) => deckIdList.includes(n.deckId)); } @@ -973,7 +976,8 @@ export const syncRepository: SyncRepository = { const fieldValueResults = await db .select() .from(noteFieldValues) - .where(gt(noteFieldValues.syncVersion, lastSyncVersion)); + .where(gt(noteFieldValues.syncVersion, lastSyncVersion)) + .orderBy(noteFieldValues.id); pulledNoteFieldValues = fieldValueResults.filter((fv) => allUserNoteIds.includes(fv.noteId), diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index a986cad..cb3a287 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -147,6 +147,7 @@ export interface CardRepository { softDelete(id: string, deckId: string): Promise<boolean>; softDeleteByNoteId(noteId: string): Promise<boolean>; findDueCards(deckId: string, now: Date, limit: number): Promise<Card[]>; + countDueCards(deckId: string, now: Date): Promise<number>; findDueCardsWithNoteData( deckId: string, now: Date, @@ -310,6 +311,21 @@ export interface CreateNoteResult { cards: Card[]; } +export interface BulkCreateNoteInput { + noteTypeId: string; + fields: Record<string, string>; +} + +export interface BulkCreateNoteFailure { + index: number; + error: string; +} + +export interface BulkCreateNoteResult { + created: number; + failed: BulkCreateNoteFailure[]; +} + export interface NoteRepository { findByDeckId(deckId: string): Promise<Note[]>; findById(id: string, deckId: string): Promise<Note | undefined>; @@ -330,4 +346,8 @@ export interface NoteRepository { fields: Record<string, string>, ): Promise<NoteWithFieldValues | undefined>; softDelete(id: string, deckId: string): Promise<boolean>; + createMany( + deckId: string, + notes: BulkCreateNoteInput[], + ): Promise<BulkCreateNoteResult>; } |
