aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 02:23:43 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 02:23:43 +0900
commit907632a6f6fbeee7d2b128303fa8e3893f5e9c0d (patch)
tree3a2efbaabf3b1fb4a95af7f2f1569a06a93e15c9 /src
parented93dd099f43dd6746276a72953485de91b49c8c (diff)
downloadkioku-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>
Diffstat (limited to 'src')
-rw-r--r--src/client/sync/pull.test.ts578
-rw-r--r--src/client/sync/push.test.ts480
-rw-r--r--src/server/repositories/sync.ts11
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