diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 02:23:43 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 02:23:43 +0900 |
| commit | 907632a6f6fbeee7d2b128303fa8e3893f5e9c0d (patch) | |
| tree | 3a2efbaabf3b1fb4a95af7f2f1569a06a93e15c9 | |
| parent | ed93dd099f43dd6746276a72953485de91b49c8c (diff) | |
| download | kioku-907632a6f6fbeee7d2b128303fa8e3893f5e9c0d.tar.gz kioku-907632a6f6fbeee7d2b128303fa8e3893f5e9c0d.tar.zst kioku-907632a6f6fbeee7d2b128303fa8e3893f5e9c0d.zip | |
test(sync): add client-side sync tests for note-related entities
Add comprehensive tests for syncing NoteType, NoteFieldType, Note, and
NoteFieldValue entities in push.test.ts and pull.test.ts. Tests cover:
- Data format conversion between local and server representations
- Applying pulled entities to local IndexedDB
- Pushing pending changes to server
- Updating existing entities during sync
- Syncing all note-related entities together
Also removes unused variable in sync repository.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | src/client/sync/pull.test.ts | 578 | ||||
| -rw-r--r-- | src/client/sync/push.test.ts | 480 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 11 |
3 files changed, 1057 insertions, 12 deletions
diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts index baf4bca..9ba678e 100644 --- a/src/client/sync/pull.test.ts +++ b/src/client/sync/pull.test.ts @@ -4,8 +4,19 @@ import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CardState, db, Rating } from "../db/index"; -import { localCardRepository, localDeckRepository } from "../db/repositories"; -import { PullService, pullResultToLocalData, type SyncPullResult } from "./pull"; +import { + localCardRepository, + localDeckRepository, + localNoteFieldTypeRepository, + localNoteFieldValueRepository, + localNoteRepository, + localNoteTypeRepository, +} from "../db/repositories"; +import { + PullService, + pullResultToLocalData, + type SyncPullResult, +} from "./pull"; import { SyncQueue } from "./queue"; function createEmptyPullResult( @@ -237,6 +248,201 @@ describe("pullResultToLocalData", () => { expect(result.reviewLogs[0]?.durationMs).toBeNull(); }); + + it("should convert server note types to local format", () => { + const serverNoteTypes = [ + { + id: "note-type-1", + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 5, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: serverNoteTypes, + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + currentSyncVersion: 5, + }); + + expect(result.noteTypes).toHaveLength(1); + expect(result.noteTypes[0]).toEqual({ + id: "note-type-1", + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 5, + _synced: true, + }); + }); + + it("should convert server note field types to local format", () => { + const serverNoteFieldTypes = [ + { + id: "field-type-1", + noteTypeId: "note-type-1", + name: "Front", + order: 0, + fieldType: "text", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 3, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: serverNoteFieldTypes, + notes: [], + noteFieldValues: [], + currentSyncVersion: 3, + }); + + expect(result.noteFieldTypes).toHaveLength(1); + expect(result.noteFieldTypes[0]).toEqual({ + id: "field-type-1", + noteTypeId: "note-type-1", + name: "Front", + order: 0, + fieldType: "text", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 3, + _synced: true, + }); + }); + + it("should convert server notes to local format", () => { + const serverNotes = [ + { + id: "note-1", + deckId: "deck-1", + noteTypeId: "note-type-1", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 2, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: serverNotes, + noteFieldValues: [], + currentSyncVersion: 2, + }); + + expect(result.notes).toHaveLength(1); + expect(result.notes[0]).toEqual({ + id: "note-1", + deckId: "deck-1", + noteTypeId: "note-type-1", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 2, + _synced: true, + }); + }); + + it("should convert server note field values to local format", () => { + const serverNoteFieldValues = [ + { + id: "field-value-1", + noteId: "note-1", + noteFieldTypeId: "field-type-1", + value: "What is the capital of Japan?", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + syncVersion: 4, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: serverNoteFieldValues, + currentSyncVersion: 4, + }); + + expect(result.noteFieldValues).toHaveLength(1); + expect(result.noteFieldValues[0]).toEqual({ + id: "field-value-1", + noteId: "note-1", + noteFieldTypeId: "field-type-1", + value: "What is the capital of Japan?", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + syncVersion: 4, + _synced: true, + }); + }); + + it("should convert server cards with noteId and isReversed to local format", () => { + const serverCards = [ + { + id: "card-1", + deckId: "deck-1", + noteId: "note-1", + isReversed: true, + front: "Question", + back: "Answer", + state: CardState.Review, + due: new Date("2024-01-05T09:00:00Z"), + stability: 10.5, + difficulty: 5.2, + elapsedDays: 3, + scheduledDays: 5, + reps: 4, + lapses: 1, + lastReview: new Date("2024-01-02T10:00:00Z"), + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 2, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: serverCards, + reviewLogs: [], + ...createEmptyPullResult(2), + }); + + expect(result.cards).toHaveLength(1); + expect(result.cards[0]?.noteId).toBe("note-1"); + expect(result.cards[0]?.isReversed).toBe(true); + }); }); describe("PullService", () => { @@ -246,6 +452,10 @@ describe("PullService", () => { await db.decks.clear(); await db.cards.clear(); await db.reviewLogs.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); localStorage.clear(); syncQueue = new SyncQueue(); }); @@ -254,6 +464,10 @@ describe("PullService", () => { await db.decks.clear(); await db.cards.clear(); await db.reviewLogs.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); localStorage.clear(); }); @@ -549,6 +763,366 @@ describe("PullService", () => { expect(result.reviewLogs).toHaveLength(1); expect(syncQueue.getLastSyncVersion()).toBe(3); }); + + it("should apply pulled note types to local database", async () => { + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [ + { + id: "note-type-1", + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 5, + }, + ], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + currentSyncVersion: 5, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const noteType = await localNoteTypeRepository.findById("note-type-1"); + expect(noteType).toBeDefined(); + expect(noteType?.name).toBe("Basic"); + expect(noteType?.frontTemplate).toBe("{{Front}}"); + expect(noteType?.isReversible).toBe(false); + expect(noteType?._synced).toBe(true); + expect(noteType?.syncVersion).toBe(5); + }); + + it("should apply pulled note field types to local database", async () => { + // First create the note type + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.markSynced(noteType.id, 1); + + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [ + { + id: "field-type-1", + noteTypeId: noteType.id, + name: "Front", + order: 0, + fieldType: "text", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 3, + }, + ], + notes: [], + noteFieldValues: [], + currentSyncVersion: 3, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const fieldType = + await localNoteFieldTypeRepository.findById("field-type-1"); + expect(fieldType).toBeDefined(); + expect(fieldType?.name).toBe("Front"); + expect(fieldType?.order).toBe(0); + expect(fieldType?._synced).toBe(true); + expect(fieldType?.syncVersion).toBe(3); + }); + + it("should apply pulled notes to local database", async () => { + // First create the deck and note type + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.markSynced(noteType.id, 1); + + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [ + { + id: "note-1", + deckId: deck.id, + noteTypeId: noteType.id, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 4, + }, + ], + noteFieldValues: [], + currentSyncVersion: 4, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const note = await localNoteRepository.findById("note-1"); + expect(note).toBeDefined(); + expect(note?.deckId).toBe(deck.id); + expect(note?.noteTypeId).toBe(noteType.id); + expect(note?._synced).toBe(true); + expect(note?.syncVersion).toBe(4); + }); + + it("should apply pulled note field values to local database", async () => { + // First create the deck, note type, field type, and note + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.markSynced(noteType.id, 1); + + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId: noteType.id, + name: "Front", + order: 0, + }); + await localNoteFieldTypeRepository.markSynced(fieldType.id, 1); + + const note = await localNoteRepository.create({ + deckId: deck.id, + noteTypeId: noteType.id, + }); + await localNoteRepository.markSynced(note.id, 1); + + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [ + { + id: "field-value-1", + noteId: note.id, + noteFieldTypeId: fieldType.id, + value: "What is 2+2?", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + syncVersion: 6, + }, + ], + currentSyncVersion: 6, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const fieldValue = + await localNoteFieldValueRepository.findById("field-value-1"); + expect(fieldValue).toBeDefined(); + expect(fieldValue?.value).toBe("What is 2+2?"); + expect(fieldValue?._synced).toBe(true); + expect(fieldValue?.syncVersion).toBe(6); + }); + + it("should handle pulling all note-related entities together", async () => { + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [ + { + id: "deck-1", + userId: "user-1", + name: "Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 1, + }, + ], + cards: [], + reviewLogs: [], + noteTypes: [ + { + id: "note-type-1", + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 2, + }, + ], + noteFieldTypes: [ + { + id: "field-type-1", + noteTypeId: "note-type-1", + name: "Front", + order: 0, + fieldType: "text", + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 3, + }, + ], + notes: [ + { + id: "note-1", + deckId: "deck-1", + noteTypeId: "note-type-1", + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 4, + }, + ], + noteFieldValues: [ + { + id: "field-value-1", + noteId: "note-1", + noteFieldTypeId: "field-type-1", + value: "What is 2+2?", + createdAt: new Date(), + updatedAt: new Date(), + syncVersion: 5, + }, + ], + currentSyncVersion: 5, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + const result = await pullService.pull(); + + expect(result.noteTypes).toHaveLength(1); + expect(result.noteFieldTypes).toHaveLength(1); + expect(result.notes).toHaveLength(1); + expect(result.noteFieldValues).toHaveLength(1); + expect(syncQueue.getLastSyncVersion()).toBe(5); + + // Verify all items are stored in local database + const noteType = await localNoteTypeRepository.findById("note-type-1"); + const fieldType = + await localNoteFieldTypeRepository.findById("field-type-1"); + const note = await localNoteRepository.findById("note-1"); + const fieldValue = + await localNoteFieldValueRepository.findById("field-value-1"); + + expect(noteType?._synced).toBe(true); + expect(fieldType?._synced).toBe(true); + expect(note?._synced).toBe(true); + expect(fieldValue?._synced).toBe(true); + }); + + it("should update existing note types when pulling", async () => { + // Create an existing note type + const existingNoteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Old Name", + frontTemplate: "{{Old}}", + backTemplate: "{{Old}}", + isReversible: false, + }); + + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [ + { + id: existingNoteType.id, + userId: "user-1", + name: "Updated Name", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + createdAt: existingNoteType.createdAt, + updatedAt: new Date(), + deletedAt: null, + syncVersion: 10, + }, + ], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + currentSyncVersion: 10, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const updatedNoteType = await localNoteTypeRepository.findById( + existingNoteType.id, + ); + expect(updatedNoteType?.name).toBe("Updated Name"); + expect(updatedNoteType?.frontTemplate).toBe("{{Front}}"); + expect(updatedNoteType?.isReversible).toBe(true); + expect(updatedNoteType?._synced).toBe(true); + }); }); describe("getLastSyncVersion", () => { diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts index ccd2c7d..9a42eff 100644 --- a/src/client/sync/push.test.ts +++ b/src/client/sync/push.test.ts @@ -3,14 +3,18 @@ */ import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CardState, db, Rating } from "../db/index"; +import { CardState, db, FieldType, Rating } from "../db/index"; import { localCardRepository, localDeckRepository, + localNoteFieldTypeRepository, + localNoteFieldValueRepository, + localNoteRepository, + localNoteTypeRepository, localReviewLogRepository, } from "../db/repositories"; -import type { PendingChanges } from "./queue"; import { PushService, pendingChangesToPushData } from "./push"; +import type { PendingChanges } from "./queue"; import { SyncQueue } from "./queue"; function createEmptyPending(): Omit< @@ -277,6 +281,193 @@ describe("pendingChangesToPushData", () => { expect(result.reviewLogs[0]?.durationMs).toBeNull(); }); + + it("should convert note types to sync format", () => { + const noteTypes = [ + { + id: "note-type-1", + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes, + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }); + + expect(result.noteTypes).toHaveLength(1); + expect(result.noteTypes[0]).toEqual({ + id: "note-type-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T15:30:00.000Z", + deletedAt: null, + }); + }); + + it("should convert note field types to sync format", () => { + const noteFieldTypes = [ + { + id: "field-type-1", + noteTypeId: "note-type-1", + name: "Front", + order: 0, + fieldType: FieldType.Text, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes, + notes: [], + noteFieldValues: [], + }); + + expect(result.noteFieldTypes).toHaveLength(1); + expect(result.noteFieldTypes[0]).toEqual({ + id: "field-type-1", + noteTypeId: "note-type-1", + name: "Front", + order: 0, + fieldType: "text", + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T15:30:00.000Z", + deletedAt: null, + }); + }); + + it("should convert notes to sync format", () => { + const notes = [ + { + id: "note-1", + deckId: "deck-1", + noteTypeId: "note-type-1", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes, + noteFieldValues: [], + }); + + expect(result.notes).toHaveLength(1); + expect(result.notes[0]).toEqual({ + id: "note-1", + deckId: "deck-1", + noteTypeId: "note-type-1", + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T15:30:00.000Z", + deletedAt: null, + }); + }); + + it("should convert note field values to sync format", () => { + const noteFieldValues = [ + { + id: "field-value-1", + noteId: "note-1", + noteFieldTypeId: "field-type-1", + value: "What is the capital of Japan?", + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues, + }); + + expect(result.noteFieldValues).toHaveLength(1); + expect(result.noteFieldValues[0]).toEqual({ + id: "field-value-1", + noteId: "note-1", + noteFieldTypeId: "field-type-1", + value: "What is the capital of Japan?", + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T15:30:00.000Z", + }); + }); + + it("should convert cards with noteId and isReversed to sync format", () => { + const cards = [ + { + id: "card-1", + deckId: "deck-1", + noteId: "note-1", + isReversed: true, + front: "Question", + back: "Answer", + state: CardState.Review, + due: new Date("2024-01-05T09:00:00Z"), + stability: 10.5, + difficulty: 5.2, + elapsedDays: 3, + scheduledDays: 5, + reps: 4, + lapses: 1, + lastReview: new Date("2024-01-02T10:00:00Z"), + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards, + reviewLogs: [], + ...createEmptyPending(), + }); + + expect(result.cards).toHaveLength(1); + expect(result.cards[0]?.noteId).toBe("note-1"); + expect(result.cards[0]?.isReversed).toBe(true); + }); }); describe("PushService", () => { @@ -286,6 +477,10 @@ describe("PushService", () => { await db.decks.clear(); await db.cards.clear(); await db.reviewLogs.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); localStorage.clear(); syncQueue = new SyncQueue(); }); @@ -294,6 +489,10 @@ describe("PushService", () => { await db.decks.clear(); await db.cards.clear(); await db.reviewLogs.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); localStorage.clear(); }); @@ -584,6 +783,283 @@ describe("PushService", () => { expect(updatedCard?._synced).toBe(true); expect(updatedLog?._synced).toBe(true); }); + + it("should push pending note types to server", async () => { + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [{ id: noteType.id, syncVersion: 1 }], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: createEmptyConflicts(), + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(pushToServer).toHaveBeenCalledWith( + expect.objectContaining({ + noteTypes: [ + expect.objectContaining({ + id: noteType.id, + name: "Basic", + }), + ], + }), + ); + expect(result.noteTypes).toHaveLength(1); + expect(result.noteTypes[0]?.id).toBe(noteType.id); + + const updatedNoteType = await localNoteTypeRepository.findById( + noteType.id, + ); + expect(updatedNoteType?._synced).toBe(true); + expect(updatedNoteType?.syncVersion).toBe(1); + }); + + it("should push pending note field types to server", async () => { + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.markSynced(noteType.id, 1); + + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId: noteType.id, + name: "Front", + order: 0, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [{ id: fieldType.id, syncVersion: 1 }], + notes: [], + noteFieldValues: [], + conflicts: createEmptyConflicts(), + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.noteFieldTypes).toHaveLength(1); + + const updatedFieldType = await localNoteFieldTypeRepository.findById( + fieldType.id, + ); + expect(updatedFieldType?._synced).toBe(true); + expect(updatedFieldType?.syncVersion).toBe(1); + }); + + it("should push pending notes to server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.markSynced(noteType.id, 1); + + const note = await localNoteRepository.create({ + deckId: deck.id, + noteTypeId: noteType.id, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [{ id: note.id, syncVersion: 1 }], + noteFieldValues: [], + conflicts: createEmptyConflicts(), + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.notes).toHaveLength(1); + + const updatedNote = await localNoteRepository.findById(note.id); + expect(updatedNote?._synced).toBe(true); + expect(updatedNote?.syncVersion).toBe(1); + }); + + it("should push pending note field values to server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.markSynced(noteType.id, 1); + + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId: noteType.id, + name: "Front", + order: 0, + }); + await localNoteFieldTypeRepository.markSynced(fieldType.id, 1); + + const note = await localNoteRepository.create({ + deckId: deck.id, + noteTypeId: noteType.id, + }); + await localNoteRepository.markSynced(note.id, 1); + + const fieldValue = await localNoteFieldValueRepository.create({ + noteId: note.id, + noteFieldTypeId: fieldType.id, + value: "What is 2+2?", + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [{ id: fieldValue.id, syncVersion: 1 }], + conflicts: createEmptyConflicts(), + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.noteFieldValues).toHaveLength(1); + + const updatedFieldValue = await localNoteFieldValueRepository.findById( + fieldValue.id, + ); + expect(updatedFieldValue?._synced).toBe(true); + expect(updatedFieldValue?.syncVersion).toBe(1); + }); + + it("should push all note-related entities together", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId: noteType.id, + name: "Front", + order: 0, + }); + + const note = await localNoteRepository.create({ + deckId: deck.id, + noteTypeId: noteType.id, + }); + + const fieldValue = await localNoteFieldValueRepository.create({ + noteId: note.id, + noteFieldTypeId: fieldType.id, + value: "What is 2+2?", + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 1 }], + cards: [], + reviewLogs: [], + noteTypes: [{ id: noteType.id, syncVersion: 1 }], + noteFieldTypes: [{ id: fieldType.id, syncVersion: 1 }], + notes: [{ id: note.id, syncVersion: 1 }], + noteFieldValues: [{ id: fieldValue.id, syncVersion: 1 }], + conflicts: createEmptyConflicts(), + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.decks).toHaveLength(1); + expect(result.noteTypes).toHaveLength(1); + expect(result.noteFieldTypes).toHaveLength(1); + expect(result.notes).toHaveLength(1); + expect(result.noteFieldValues).toHaveLength(1); + + // Verify all items are marked as synced + const updatedNoteType = await localNoteTypeRepository.findById( + noteType.id, + ); + const updatedFieldType = await localNoteFieldTypeRepository.findById( + fieldType.id, + ); + const updatedNote = await localNoteRepository.findById(note.id); + const updatedFieldValue = await localNoteFieldValueRepository.findById( + fieldValue.id, + ); + + expect(updatedNoteType?._synced).toBe(true); + expect(updatedFieldType?._synced).toBe(true); + expect(updatedNote?._synced).toBe(true); + expect(updatedFieldValue?._synced).toBe(true); + }); }); describe("hasPendingChanges", () => { diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts index ac9b336..8c4fd25 100644 --- a/src/server/repositories/sync.ts +++ b/src/server/repositories/sync.ts @@ -5,8 +5,8 @@ import { decks, noteFieldTypes, noteFieldValues, - noteTypes, notes, + noteTypes, reviewLogs, } from "../db/schema.js"; import type { @@ -634,9 +634,7 @@ export const syncRepository: SyncRepository = { noteTypeId: noteData.noteTypeId, createdAt: new Date(noteData.createdAt), updatedAt: clientUpdatedAt, - deletedAt: noteData.deletedAt - ? new Date(noteData.deletedAt) - : null, + deletedAt: noteData.deletedAt ? new Date(noteData.deletedAt) : null, syncVersion: 1, }) .returning({ id: notes.id, syncVersion: notes.syncVersion }); @@ -860,10 +858,7 @@ export const syncRepository: SyncRepository = { pulledNotes = noteResults.filter((n) => deckIdList.includes(n.deckId)); } - // Get note IDs for filtering note field values - const noteIdList = pulledNotes.map((n) => n.id); - - // Also get all user's note IDs (not just recently synced ones) for field value filtering + // Get all user's note IDs (not just recently synced ones) for field value filtering let allUserNoteIds: string[] = []; if (deckIdList.length > 0) { const allNotes = await db |
