diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:26:57 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:26:57 +0900 |
| commit | 842c74fdc2bf06a020868f5b4e504fec0da8715d (patch) | |
| tree | 8620a9ddb4211f449faaae98776a62ed8101fae3 /src/client/sync/push.test.ts | |
| parent | 83be5ccd5d64c64f65c7efbfb9feb94ab0f75ce6 (diff) | |
| download | kioku-842c74fdc2bf06a020868f5b4e504fec0da8715d.tar.gz kioku-842c74fdc2bf06a020868f5b4e504fec0da8715d.tar.zst kioku-842c74fdc2bf06a020868f5b4e504fec0da8715d.zip | |
feat(client): add push service for sync implementation
Implement PushService class to push local changes to server:
- Convert local decks, cards, and review logs to API format
- Push pending changes to server endpoint
- Mark items as synced after successful push
- Return conflicts reported by server (LWW resolution)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/sync/push.test.ts')
| -rw-r--r-- | src/client/sync/push.test.ts | 545 |
1 files changed, 545 insertions, 0 deletions
diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts new file mode 100644 index 0000000..79a9d4a --- /dev/null +++ b/src/client/sync/push.test.ts @@ -0,0 +1,545 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CardState, db, Rating } from "../db/index"; +import { + localCardRepository, + localDeckRepository, + localReviewLogRepository, +} from "../db/repositories"; +import { pendingChangesToPushData, PushService } from "./push"; +import { SyncQueue } from "./queue"; + +describe("pendingChangesToPushData", () => { + it("should convert decks to sync format", () => { + const decks = [ + { + id: "deck-1", + userId: "user-1", + name: "Test Deck", + description: "A description", + newCardsPerDay: 20, + 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: [], + }); + + expect(result.decks).toHaveLength(1); + expect(result.decks[0]).toEqual({ + id: "deck-1", + name: "Test Deck", + description: "A description", + newCardsPerDay: 20, + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T15:30:00.000Z", + deletedAt: null, + }); + }); + + it("should convert deleted decks with deletedAt timestamp", () => { + const decks = [ + { + id: "deck-1", + userId: "user-1", + name: "Deleted Deck", + description: null, + newCardsPerDay: 10, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-03T12:00:00Z"), + deletedAt: new Date("2024-01-03T12:00:00Z"), + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks, + cards: [], + reviewLogs: [], + }); + + expect(result.decks[0]?.deletedAt).toBe("2024-01-03T12:00:00.000Z"); + }); + + it("should convert cards to sync format", () => { + const cards = [ + { + id: "card-1", + deckId: "deck-1", + 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: [], + }); + + expect(result.cards).toHaveLength(1); + expect(result.cards[0]).toEqual({ + id: "card-1", + deckId: "deck-1", + front: "Question", + back: "Answer", + state: CardState.Review, + due: "2024-01-05T09:00:00.000Z", + stability: 10.5, + difficulty: 5.2, + elapsedDays: 3, + scheduledDays: 5, + reps: 4, + lapses: 1, + lastReview: "2024-01-02T10:00:00.000Z", + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T10:00:00.000Z", + deletedAt: null, + }); + }); + + it("should convert cards with null lastReview", () => { + const cards = [ + { + id: "card-1", + deckId: "deck-1", + front: "New Card", + back: "Answer", + state: CardState.New, + due: new Date("2024-01-01T10:00:00Z"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-01T10:00:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards, + reviewLogs: [], + }); + + expect(result.cards[0]?.lastReview).toBeNull(); + }); + + it("should convert review logs to sync format", () => { + const reviewLogs = [ + { + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.Learning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date("2024-01-02T10:00:00Z"), + durationMs: 5000, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs, + }); + + expect(result.reviewLogs).toHaveLength(1); + expect(result.reviewLogs[0]).toEqual({ + id: "log-1", + cardId: "card-1", + rating: Rating.Good, + state: CardState.Learning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: "2024-01-02T10:00:00.000Z", + durationMs: 5000, + }); + }); + + it("should convert review logs with null durationMs", () => { + const reviewLogs = [ + { + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Easy, + state: CardState.New, + scheduledDays: 3, + elapsedDays: 0, + reviewedAt: new Date("2024-01-02T10:00:00Z"), + durationMs: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs, + }); + + expect(result.reviewLogs[0]?.durationMs).toBeNull(); + }); +}); + +describe("PushService", () => { + let syncQueue: SyncQueue; + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + syncQueue = new SyncQueue(); + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + }); + + describe("push", () => { + it("should return empty result when no pending changes", async () => { + const pushToServer = vi.fn(); + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result).toEqual({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + expect(pushToServer).not.toHaveBeenCalled(); + }); + + it("should push pending decks to server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 1 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(pushToServer).toHaveBeenCalledTimes(1); + expect(pushToServer).toHaveBeenCalledWith({ + decks: [ + expect.objectContaining({ + id: deck.id, + name: "Test Deck", + }), + ], + cards: [], + reviewLogs: [], + }); + expect(result.decks).toHaveLength(1); + expect(result.decks[0]?.id).toBe(deck.id); + }); + + it("should push pending cards 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 card = await localCardRepository.create({ + deckId: deck.id, + front: "Question", + back: "Answer", + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [{ id: card.id, syncVersion: 1 }], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(pushToServer).toHaveBeenCalledWith({ + decks: [], + cards: [ + expect.objectContaining({ + id: card.id, + front: "Question", + back: "Answer", + }), + ], + reviewLogs: [], + }); + expect(result.cards).toHaveLength(1); + }); + + it("should push pending review logs 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 card = await localCardRepository.create({ + deckId: deck.id, + front: "Q", + back: "A", + }); + await localCardRepository.markSynced(card.id, 1); + + const log = await localReviewLogRepository.create({ + cardId: card.id, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [{ id: log.id, syncVersion: 1 }], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(pushToServer).toHaveBeenCalledWith({ + decks: [], + cards: [], + reviewLogs: [ + expect.objectContaining({ + id: log.id, + rating: Rating.Good, + }), + ], + }); + expect(result.reviewLogs).toHaveLength(1); + }); + + it("should mark items as synced after successful push", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 5 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + await pushService.push(); + + const updatedDeck = await localDeckRepository.findById(deck.id); + expect(updatedDeck?._synced).toBe(true); + expect(updatedDeck?.syncVersion).toBe(5); + }); + + it("should return conflicts from server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 3 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [deck.id], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.conflicts.decks).toContain(deck.id); + }); + + it("should throw error if push fails", async () => { + await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockRejectedValue(new Error("Network error")); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + await expect(pushService.push()).rejects.toThrow("Network error"); + }); + + it("should push all types of changes together", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Q", + back: "A", + }); + + const log = await localReviewLogRepository.create({ + cardId: card.id, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 1 }], + cards: [{ id: card.id, syncVersion: 1 }], + reviewLogs: [{ id: log.id, syncVersion: 1 }], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.decks).toHaveLength(1); + expect(result.cards).toHaveLength(1); + expect(result.reviewLogs).toHaveLength(1); + + // Verify all items are marked as synced + const updatedDeck = await localDeckRepository.findById(deck.id); + const updatedCard = await localCardRepository.findById(card.id); + const updatedLog = await localReviewLogRepository.findById(log.id); + + expect(updatedDeck?._synced).toBe(true); + expect(updatedCard?._synced).toBe(true); + expect(updatedLog?._synced).toBe(true); + }); + }); + + describe("hasPendingChanges", () => { + it("should return false when no pending changes", async () => { + const pushService = new PushService({ + syncQueue, + pushToServer: vi.fn(), + }); + + const result = await pushService.hasPendingChanges(); + expect(result).toBe(false); + }); + + it("should return true when there are pending changes", async () => { + await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer: vi.fn(), + }); + + const result = await pushService.hasPendingChanges(); + expect(result).toBe(true); + }); + }); +}); |
