aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync/pull.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/sync/pull.test.ts')
-rw-r--r--src/client/sync/pull.test.ts491
1 files changed, 491 insertions, 0 deletions
diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts
index dd562a0..8bbf7cf 100644
--- a/src/client/sync/pull.test.ts
+++ b/src/client/sync/pull.test.ts
@@ -13,6 +13,15 @@ import {
localNoteTypeRepository,
} from "../db/repositories";
import {
+ binaryToBase64,
+ CrdtEntityType,
+ crdtDeckRepository,
+ crdtNoteTypeRepository,
+ crdtSyncDb,
+ crdtSyncStateManager,
+} from "./crdt";
+import {
+ applyCrdtChanges,
PullService,
pullResultToLocalData,
type SyncPullResult,
@@ -1151,3 +1160,485 @@ describe("PullService", () => {
});
});
});
+
+describe("applyCrdtChanges", () => {
+ beforeEach(async () => {
+ await crdtSyncDb.syncState.clear();
+ await crdtSyncDb.metadata.clear();
+ });
+
+ afterEach(async () => {
+ await crdtSyncDb.syncState.clear();
+ await crdtSyncDb.metadata.clear();
+ });
+
+ it("should process CRDT payload for a new deck", async () => {
+ // Create a CRDT document from a deck
+ const deck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test Deck",
+ description: "A test description",
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-02T15:30:00Z"),
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+ const crdtResult = crdtDeckRepository.toCrdtDocument(deck);
+
+ const payload = {
+ documentId: crdtResult.documentId,
+ entityType: CrdtEntityType.Deck,
+ entityId: deck.id,
+ binary: binaryToBase64(crdtResult.binary),
+ };
+
+ const result = await applyCrdtChanges([payload], 5);
+
+ expect(result.created).toBe(1);
+ expect(result.merged).toBe(0);
+ expect(result.entities.decks).toHaveLength(1);
+ expect(result.entities.decks[0]?.id).toBe("deck-1");
+ expect(result.entities.decks[0]?.name).toBe("Test Deck");
+ expect(result.entities.decks[0]?.description).toBe("A test description");
+ });
+
+ it("should merge CRDT payload with existing local document", async () => {
+ // Create an initial local CRDT document
+ const localDeck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Local Deck",
+ description: "Local description",
+ newCardsPerDay: 10,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-01T12:00:00Z"),
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true as const,
+ };
+ const localCrdtResult = crdtDeckRepository.toCrdtDocument(localDeck);
+
+ // Store the local CRDT binary
+ await crdtSyncStateManager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ localDeck.id,
+ localCrdtResult.binary,
+ 1,
+ );
+
+ // Create a remote CRDT document with updated data
+ const remoteDeck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Remote Deck",
+ description: "Remote description",
+ newCardsPerDay: 25,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-02T15:30:00Z"), // Later timestamp
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+ const remoteCrdtResult = crdtDeckRepository.toCrdtDocument(remoteDeck);
+
+ const payload = {
+ documentId: remoteCrdtResult.documentId,
+ entityType: CrdtEntityType.Deck,
+ entityId: remoteDeck.id,
+ binary: binaryToBase64(remoteCrdtResult.binary),
+ };
+
+ const result = await applyCrdtChanges([payload], 5);
+
+ expect(result.created).toBe(0);
+ expect(result.merged).toBe(1);
+ expect(result.entities.decks).toHaveLength(1);
+ // The merged result should reflect the remote changes
+ expect(result.entities.decks[0]?.id).toBe("deck-1");
+ });
+
+ it("should process multiple CRDT payloads", async () => {
+ const deck1 = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Deck 1",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-02T15:30:00Z"),
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+ const deck2 = {
+ id: "deck-2",
+ userId: "user-1",
+ name: "Deck 2",
+ description: "Second deck",
+ newCardsPerDay: 15,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-02T15:30:00Z"),
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+
+ const crdtResult1 = crdtDeckRepository.toCrdtDocument(deck1);
+ const crdtResult2 = crdtDeckRepository.toCrdtDocument(deck2);
+
+ const payloads = [
+ {
+ documentId: crdtResult1.documentId,
+ entityType: CrdtEntityType.Deck,
+ entityId: deck1.id,
+ binary: binaryToBase64(crdtResult1.binary),
+ },
+ {
+ documentId: crdtResult2.documentId,
+ entityType: CrdtEntityType.Deck,
+ entityId: deck2.id,
+ binary: binaryToBase64(crdtResult2.binary),
+ },
+ ];
+
+ const result = await applyCrdtChanges(payloads, 5);
+
+ expect(result.created).toBe(2);
+ expect(result.merged).toBe(0);
+ expect(result.entities.decks).toHaveLength(2);
+ });
+
+ it("should process CRDT payloads for different entity types", async () => {
+ const deck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-02T15:30:00Z"),
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+
+ const noteType = {
+ 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-02T15:30:00Z"),
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+
+ const deckCrdt = crdtDeckRepository.toCrdtDocument(deck);
+ const noteTypeCrdt = crdtNoteTypeRepository.toCrdtDocument(noteType);
+
+ const payloads = [
+ {
+ documentId: deckCrdt.documentId,
+ entityType: CrdtEntityType.Deck,
+ entityId: deck.id,
+ binary: binaryToBase64(deckCrdt.binary),
+ },
+ {
+ documentId: noteTypeCrdt.documentId,
+ entityType: CrdtEntityType.NoteType,
+ entityId: noteType.id,
+ binary: binaryToBase64(noteTypeCrdt.binary),
+ },
+ ];
+
+ const result = await applyCrdtChanges(payloads, 5);
+
+ expect(result.created).toBe(2);
+ expect(result.entities.decks).toHaveLength(1);
+ expect(result.entities.noteTypes).toHaveLength(1);
+ expect(result.entities.decks[0]?.name).toBe("Test Deck");
+ expect(result.entities.noteTypes[0]?.name).toBe("Basic");
+ });
+
+ it("should store merged binary in sync state", async () => {
+ const deck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-02T15:30:00Z"),
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+ const crdtResult = crdtDeckRepository.toCrdtDocument(deck);
+
+ const payload = {
+ documentId: crdtResult.documentId,
+ entityType: CrdtEntityType.Deck,
+ entityId: deck.id,
+ binary: binaryToBase64(crdtResult.binary),
+ };
+
+ await applyCrdtChanges([payload], 5);
+
+ // Verify the binary was stored in sync state
+ const storedBinary = await crdtSyncStateManager.getDocumentBinary(
+ CrdtEntityType.Deck,
+ deck.id,
+ );
+
+ expect(storedBinary).toBeDefined();
+ // Check that it's a typed array with length > 0
+ expect(storedBinary?.length).toBeGreaterThan(0);
+ });
+
+ it("should skip invalid document IDs", async () => {
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const payload = {
+ documentId: "invalid-format",
+ entityType: CrdtEntityType.Deck,
+ entityId: "deck-1",
+ binary: "SGVsbG8=", // "Hello" in base64
+ };
+
+ const result = await applyCrdtChanges([payload], 5);
+
+ expect(result.created).toBe(0);
+ expect(result.merged).toBe(0);
+ expect(result.entities.decks).toHaveLength(0);
+ expect(consoleWarn).toHaveBeenCalledWith(
+ "Invalid document ID: invalid-format",
+ );
+
+ consoleWarn.mockRestore();
+ });
+
+ it("should return empty result for empty payloads", async () => {
+ const result = await applyCrdtChanges([], 5);
+
+ expect(result.created).toBe(0);
+ expect(result.merged).toBe(0);
+ expect(result.entities.decks).toHaveLength(0);
+ expect(result.entities.noteTypes).toHaveLength(0);
+ expect(result.entities.cards).toHaveLength(0);
+ });
+});
+
+describe("PullService with CRDT changes", () => {
+ let syncQueue: SyncQueue;
+
+ beforeEach(async () => {
+ 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();
+ await crdtSyncDb.syncState.clear();
+ await crdtSyncDb.metadata.clear();
+ localStorage.clear();
+ syncQueue = new SyncQueue();
+ });
+
+ afterEach(async () => {
+ 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();
+ await crdtSyncDb.syncState.clear();
+ await crdtSyncDb.metadata.clear();
+ localStorage.clear();
+ });
+
+ it("should process CRDT changes when present in pull response", async () => {
+ const deck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "CRDT Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01T10:00:00Z"),
+ updatedAt: new Date("2024-01-02T15:30:00Z"),
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: false as const,
+ };
+ const crdtResult = crdtDeckRepository.toCrdtDocument(deck);
+
+ const pullFromServer = vi.fn().mockResolvedValue({
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [
+ {
+ documentId: crdtResult.documentId,
+ entityType: CrdtEntityType.Deck,
+ entityId: deck.id,
+ binary: binaryToBase64(crdtResult.binary),
+ },
+ ],
+ currentSyncVersion: 5,
+ });
+
+ const pullService = new PullService({
+ syncQueue,
+ pullFromServer,
+ });
+
+ await pullService.pull();
+
+ // Verify CRDT binary was stored
+ const storedBinary = await crdtSyncStateManager.getDocumentBinary(
+ CrdtEntityType.Deck,
+ deck.id,
+ );
+ expect(storedBinary).toBeDefined();
+ });
+
+ it("should handle pull response without CRDT changes", async () => {
+ const pullFromServer = vi.fn().mockResolvedValue({
+ decks: [
+ {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 1,
+ },
+ ],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ // No crdtChanges field
+ currentSyncVersion: 1,
+ });
+
+ const pullService = new PullService({
+ syncQueue,
+ pullFromServer,
+ });
+
+ // Should not throw even without crdtChanges
+ const result = await pullService.pull();
+
+ expect(result.decks).toHaveLength(1);
+ expect(syncQueue.getLastSyncVersion()).toBe(1);
+ });
+
+ it("should handle empty CRDT changes array", async () => {
+ const pullFromServer = vi.fn().mockResolvedValue({
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [],
+ currentSyncVersion: 1,
+ });
+
+ const pullService = new PullService({
+ syncQueue,
+ pullFromServer,
+ });
+
+ const result = await pullService.pull();
+
+ expect(result.crdtChanges).toHaveLength(0);
+ expect(syncQueue.getLastSyncVersion()).toBe(1);
+ });
+
+ it("should process both regular data and CRDT changes", async () => {
+ // Create CRDT payload for a note type
+ const noteType = {
+ id: "note-type-1",
+ userId: "user-1",
+ name: "CRDT NoteType",
+ 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: false as const,
+ };
+ const crdtResult = crdtNoteTypeRepository.toCrdtDocument(noteType);
+
+ const pullFromServer = vi.fn().mockResolvedValue({
+ decks: [
+ {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Regular Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 5,
+ },
+ ],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [
+ {
+ documentId: crdtResult.documentId,
+ entityType: CrdtEntityType.NoteType,
+ entityId: noteType.id,
+ binary: binaryToBase64(crdtResult.binary),
+ },
+ ],
+ currentSyncVersion: 5,
+ });
+
+ const pullService = new PullService({
+ syncQueue,
+ pullFromServer,
+ });
+
+ await pullService.pull();
+
+ // Verify regular deck was applied
+ const storedDeck = await localDeckRepository.findById("deck-1");
+ expect(storedDeck).toBeDefined();
+ expect(storedDeck?.name).toBe("Regular Deck");
+
+ // Verify CRDT binary was stored for note type
+ const storedBinary = await crdtSyncStateManager.getDocumentBinary(
+ CrdtEntityType.NoteType,
+ noteType.id,
+ );
+ expect(storedBinary).toBeDefined();
+ });
+});