diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:16:48 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:16:48 +0900 |
| commit | fe101104cdd50256d4ef5c61e1bf099ed2da68e3 (patch) | |
| tree | 862d84bd685dcbea6fe1bb2fc02f1cad33049196 | |
| parent | c086c8b35b6c6f0b0e2623e9b6421713a540941a (diff) | |
| download | kioku-fe101104cdd50256d4ef5c61e1bf099ed2da68e3.tar.gz kioku-fe101104cdd50256d4ef5c61e1bf099ed2da68e3.tar.zst kioku-fe101104cdd50256d4ef5c61e1bf099ed2da68e3.zip | |
feat(server): add POST /api/sync/push endpoint
Implement sync push endpoint with Last-Write-Wins conflict resolution.
Includes Zod validation for decks, cards, and review logs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 29 | ||||
| -rw-r--r-- | src/server/index.ts | 5 | ||||
| -rw-r--r-- | src/server/repositories/index.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 279 | ||||
| -rw-r--r-- | src/server/routes/index.ts | 1 | ||||
| -rw-r--r-- | src/server/routes/sync.test.ts | 441 | ||||
| -rw-r--r-- | src/server/routes/sync.ts | 78 |
8 files changed, 821 insertions, 15 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index e98d72f..453bd25 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -153,7 +153,7 @@ Smaller features first to enable early MVP validation. - [x] Add tests ### Sync Engine -- [ ] POST /api/sync/push endpoint +- [x] POST /api/sync/push endpoint - [ ] GET /api/sync/pull endpoint - [ ] Client: Sync queue management - [ ] Client: Push implementation diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 05e9943..c6f8665 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -144,17 +144,14 @@ export function StudyPage() { throw new ApiClientError("Not authenticated", 401); } - const res = await fetch( - `/api/decks/${deckId}/study/${currentCard.id}`, - { - method: "POST", - headers: { - ...authHeader, - "Content-Type": "application/json", - }, - body: JSON.stringify({ rating, durationMs }), + const res = await fetch(`/api/decks/${deckId}/study/${currentCard.id}`, { + method: "POST", + headers: { + ...authHeader, + "Content-Type": "application/json", }, - ); + body: JSON.stringify({ rating, durationMs }), + }); if (!res.ok) { const errorBody = await res.json().catch(() => ({})); @@ -312,7 +309,13 @@ export function StudyPage() { <strong data-testid="completed-count">{completedCount}</strong>{" "} card{completedCount !== 1 ? "s" : ""}. </p> - <div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}> + <div + style={{ + display: "flex", + gap: "1rem", + justifyContent: "center", + }} + > <Link href={`/decks/${deckId}`}> <button type="button">Back to Deck</button> </Link> @@ -336,7 +339,9 @@ export function StudyPage() { }} role="button" tabIndex={0} - aria-label={isFlipped ? "Card showing answer" : "Click to reveal answer"} + aria-label={ + isFlipped ? "Card showing answer" : "Click to reveal answer" + } style={{ border: "1px solid #ccc", borderRadius: "8px", diff --git a/src/server/index.ts b/src/server/index.ts index 0391119..a2a3a77 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,7 +2,7 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { logger } from "hono/logger"; import { errorHandler } from "./middleware/index.js"; -import { auth, cards, decks, study } from "./routes/index.js"; +import { auth, cards, decks, study, sync } from "./routes/index.js"; const app = new Hono(); @@ -20,7 +20,8 @@ const routes = app .route("/api/auth", auth) .route("/api/decks", decks) .route("/api/decks/:deckId/cards", cards) - .route("/api/decks/:deckId/study", study); + .route("/api/decks/:deckId/study", study) + .route("/api/sync", sync); export type AppType = typeof routes; diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts index e909a9b..7770d60 100644 --- a/src/server/repositories/index.ts +++ b/src/server/repositories/index.ts @@ -2,5 +2,6 @@ export { cardRepository } from "./card.js"; export { deckRepository } from "./deck.js"; export { refreshTokenRepository } from "./refresh-token.js"; export { reviewLogRepository } from "./review-log.js"; +export { syncRepository } from "./sync.js"; export * from "./types.js"; export { userRepository } from "./user.js"; diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts new file mode 100644 index 0000000..3051121 --- /dev/null +++ b/src/server/repositories/sync.ts @@ -0,0 +1,279 @@ +import { and, eq, gt, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { cards, decks, reviewLogs } from "../db/schema.js"; +import type { Card, Deck, ReviewLog } from "./types.js"; + +/** + * Sync data types for push/pull operations + */ +export interface SyncPushData { + decks: SyncDeckData[]; + cards: SyncCardData[]; + reviewLogs: SyncReviewLogData[]; +} + +export interface SyncDeckData { + id: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncCardData { + id: string; + deckId: string; + front: string; + back: string; + state: number; + due: string; + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncReviewLogData { + id: string; + cardId: string; + rating: number; + state: number; + scheduledDays: number; + elapsedDays: number; + reviewedAt: string; + durationMs: number | null; +} + +export interface SyncPushResult { + decks: { id: string; syncVersion: number }[]; + cards: { id: string; syncVersion: number }[]; + reviewLogs: { id: string; syncVersion: number }[]; + conflicts: { + decks: string[]; + cards: string[]; + }; +} + +export interface SyncRepository { + pushChanges(userId: string, data: SyncPushData): Promise<SyncPushResult>; +} + +export const syncRepository: SyncRepository = { + async pushChanges(userId: string, data: SyncPushData): Promise<SyncPushResult> { + const result: SyncPushResult = { + decks: [], + cards: [], + reviewLogs: [], + conflicts: { + decks: [], + cards: [], + }, + }; + + // Process decks with Last-Write-Wins conflict resolution + for (const deckData of data.decks) { + const clientUpdatedAt = new Date(deckData.updatedAt); + + // Check if deck exists + const existing = await db + .select({ id: decks.id, updatedAt: decks.updatedAt, syncVersion: decks.syncVersion }) + .from(decks) + .where(and(eq(decks.id, deckData.id), eq(decks.userId, userId))); + + if (existing.length === 0) { + // New deck - insert + const [inserted] = await db + .insert(decks) + .values({ + id: deckData.id, + userId, + name: deckData.name, + description: deckData.description, + newCardsPerDay: deckData.newCardsPerDay, + createdAt: new Date(deckData.createdAt), + updatedAt: clientUpdatedAt, + deletedAt: deckData.deletedAt ? new Date(deckData.deletedAt) : null, + syncVersion: 1, + }) + .returning({ id: decks.id, syncVersion: decks.syncVersion }); + + if (inserted) { + result.decks.push({ id: inserted.id, syncVersion: inserted.syncVersion }); + } + } else { + const serverDeck = existing[0]; + // Last-Write-Wins: compare timestamps + if (serverDeck && clientUpdatedAt > serverDeck.updatedAt) { + // Client wins - update + const [updated] = await db + .update(decks) + .set({ + name: deckData.name, + description: deckData.description, + newCardsPerDay: deckData.newCardsPerDay, + updatedAt: clientUpdatedAt, + deletedAt: deckData.deletedAt ? new Date(deckData.deletedAt) : null, + syncVersion: sql`${decks.syncVersion} + 1`, + }) + .where(eq(decks.id, deckData.id)) + .returning({ id: decks.id, syncVersion: decks.syncVersion }); + + if (updated) { + result.decks.push({ id: updated.id, syncVersion: updated.syncVersion }); + } + } else if (serverDeck) { + // Server wins - mark as conflict + result.conflicts.decks.push(deckData.id); + result.decks.push({ id: serverDeck.id, syncVersion: serverDeck.syncVersion }); + } + } + } + + // Process cards with Last-Write-Wins conflict resolution + for (const cardData of data.cards) { + const clientUpdatedAt = new Date(cardData.updatedAt); + + // Verify deck belongs to user + const deckCheck = await db + .select({ id: decks.id }) + .from(decks) + .where(and(eq(decks.id, cardData.deckId), eq(decks.userId, userId))); + + if (deckCheck.length === 0) { + // Deck doesn't belong to user, skip + continue; + } + + // Check if card exists + const existing = await db + .select({ id: cards.id, updatedAt: cards.updatedAt, syncVersion: cards.syncVersion }) + .from(cards) + .where(eq(cards.id, cardData.id)); + + if (existing.length === 0) { + // New card - insert + const [inserted] = await db + .insert(cards) + .values({ + id: cardData.id, + deckId: cardData.deckId, + front: cardData.front, + back: cardData.back, + state: cardData.state, + due: new Date(cardData.due), + stability: cardData.stability, + difficulty: cardData.difficulty, + elapsedDays: cardData.elapsedDays, + scheduledDays: cardData.scheduledDays, + reps: cardData.reps, + lapses: cardData.lapses, + lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null, + createdAt: new Date(cardData.createdAt), + updatedAt: clientUpdatedAt, + deletedAt: cardData.deletedAt ? new Date(cardData.deletedAt) : null, + syncVersion: 1, + }) + .returning({ id: cards.id, syncVersion: cards.syncVersion }); + + if (inserted) { + result.cards.push({ id: inserted.id, syncVersion: inserted.syncVersion }); + } + } else { + const serverCard = existing[0]; + // Last-Write-Wins: compare timestamps + if (serverCard && clientUpdatedAt > serverCard.updatedAt) { + // Client wins - update + const [updated] = await db + .update(cards) + .set({ + deckId: cardData.deckId, + front: cardData.front, + back: cardData.back, + state: cardData.state, + due: new Date(cardData.due), + stability: cardData.stability, + difficulty: cardData.difficulty, + elapsedDays: cardData.elapsedDays, + scheduledDays: cardData.scheduledDays, + reps: cardData.reps, + lapses: cardData.lapses, + lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null, + updatedAt: clientUpdatedAt, + deletedAt: cardData.deletedAt ? new Date(cardData.deletedAt) : null, + syncVersion: sql`${cards.syncVersion} + 1`, + }) + .where(eq(cards.id, cardData.id)) + .returning({ id: cards.id, syncVersion: cards.syncVersion }); + + if (updated) { + result.cards.push({ id: updated.id, syncVersion: updated.syncVersion }); + } + } else if (serverCard) { + // Server wins - mark as conflict + result.conflicts.cards.push(cardData.id); + result.cards.push({ id: serverCard.id, syncVersion: serverCard.syncVersion }); + } + } + } + + // Process review logs (append-only, no conflicts) + for (const logData of data.reviewLogs) { + // Verify the card's deck belongs to user + const cardCheck = await db + .select({ id: cards.id }) + .from(cards) + .innerJoin(decks, eq(cards.deckId, decks.id)) + .where(and(eq(cards.id, logData.cardId), eq(decks.userId, userId))); + + if (cardCheck.length === 0) { + // Card doesn't belong to user, skip + continue; + } + + // Check if review log already exists + const existing = await db + .select({ id: reviewLogs.id, syncVersion: reviewLogs.syncVersion }) + .from(reviewLogs) + .where(eq(reviewLogs.id, logData.id)); + + if (existing.length === 0) { + // New review log - insert + const [inserted] = await db + .insert(reviewLogs) + .values({ + id: logData.id, + cardId: logData.cardId, + userId, + rating: logData.rating, + state: logData.state, + scheduledDays: logData.scheduledDays, + elapsedDays: logData.elapsedDays, + reviewedAt: new Date(logData.reviewedAt), + durationMs: logData.durationMs, + syncVersion: 1, + }) + .returning({ id: reviewLogs.id, syncVersion: reviewLogs.syncVersion }); + + if (inserted) { + result.reviewLogs.push({ id: inserted.id, syncVersion: inserted.syncVersion }); + } + } else { + // Already exists, return current version + const existingLog = existing[0]; + if (existingLog) { + result.reviewLogs.push({ id: existingLog.id, syncVersion: existingLog.syncVersion }); + } + } + } + + return result; + }, +}; diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index e702044..08f42df 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -2,3 +2,4 @@ export { auth } from "./auth.js"; export { cards } from "./cards.js"; export { decks } from "./decks.js"; export { study } from "./study.js"; +export { sync } from "./sync.js"; diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts new file mode 100644 index 0000000..67f85e8 --- /dev/null +++ b/src/server/routes/sync.test.ts @@ -0,0 +1,441 @@ +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import type { + SyncPushData, + SyncPushResult, + SyncRepository, +} from "../repositories/sync.js"; +import { createSyncRouter } from "./sync.js"; + +function createMockSyncRepo(): SyncRepository { + return { + pushChanges: vi.fn(), + }; +} + +const JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +async function createTestToken(userId: string): Promise<string> { + const now = Math.floor(Date.now() / 1000); + return sign( + { + sub: userId, + iat: now, + exp: now + 900, + }, + JWT_SECRET, + ); +} + +interface SyncPushResponse { + decks?: { id: string; syncVersion: number }[]; + cards?: { id: string; syncVersion: number }[]; + reviewLogs?: { id: string; syncVersion: number }[]; + conflicts?: { + decks: string[]; + cards: string[]; + }; + error?: { + code: string; + message: string; + }; +} + +describe("POST /api/sync/push", () => { + let app: Hono; + let mockSyncRepo: ReturnType<typeof createMockSyncRepo>; + let authToken: string; + const userId = "user-uuid-123"; + + beforeEach(async () => { + vi.clearAllMocks(); + mockSyncRepo = createMockSyncRepo(); + const syncRouter = createSyncRouter({ syncRepo: mockSyncRepo }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/sync", syncRouter); + authToken = await createTestToken(userId); + }); + + it("returns 401 without authentication", async () => { + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ decks: [], cards: [], reviewLogs: [] }), + }); + + expect(res.status).toBe(401); + }); + + it("successfully pushes empty data", async () => { + const mockResult: SyncPushResult = { + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ decks: [], cards: [], reviewLogs: [] }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.decks).toEqual([]); + expect(body.cards).toEqual([]); + expect(body.reviewLogs).toEqual([]); + expect(body.conflicts).toEqual({ decks: [], cards: [] }); + expect(mockSyncRepo.pushChanges).toHaveBeenCalledWith(userId, { + decks: [], + cards: [], + reviewLogs: [], + }); + }); + + it("successfully pushes decks", async () => { + const deckData = { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Test Deck", + description: "A test deck", + newCardsPerDay: 20, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-02T00:00:00.000Z", + deletedAt: null, + }; + + const mockResult: SyncPushResult = { + decks: [{ id: "deck-uuid-123", syncVersion: 1 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ decks: [deckData], cards: [], reviewLogs: [] }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.decks).toHaveLength(1); + expect(body.decks?.[0]?.id).toBe("deck-uuid-123"); + expect(body.decks?.[0]?.syncVersion).toBe(1); + }); + + it("successfully pushes cards", async () => { + const cardData = { + id: "550e8400-e29b-41d4-a716-446655440001", + deckId: "550e8400-e29b-41d4-a716-446655440000", + front: "Question", + back: "Answer", + state: 0, + due: "2024-01-01T00:00:00.000Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-02T00:00:00.000Z", + deletedAt: null, + }; + + const mockResult: SyncPushResult = { + decks: [], + cards: [{ id: "550e8400-e29b-41d4-a716-446655440001", syncVersion: 1 }], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ decks: [], cards: [cardData], reviewLogs: [] }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.cards).toHaveLength(1); + expect(body.cards?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440001"); + }); + + it("successfully pushes review logs", async () => { + const reviewLogData = { + id: "550e8400-e29b-41d4-a716-446655440002", + cardId: "550e8400-e29b-41d4-a716-446655440001", + rating: 3, + state: 0, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: "2024-01-01T00:00:00.000Z", + durationMs: 5000, + }; + + const mockResult: SyncPushResult = { + decks: [], + cards: [], + reviewLogs: [{ id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 }], + conflicts: { decks: [], cards: [] }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decks: [], + cards: [], + reviewLogs: [reviewLogData], + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.reviewLogs).toHaveLength(1); + expect(body.reviewLogs?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440002"); + }); + + it("returns conflicts when server data is newer", async () => { + const deckData = { + id: "550e8400-e29b-41d4-a716-446655440003", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-02T00:00:00.000Z", + deletedAt: null, + }; + + const mockResult: SyncPushResult = { + decks: [{ id: "550e8400-e29b-41d4-a716-446655440003", syncVersion: 5 }], + cards: [], + reviewLogs: [], + conflicts: { decks: ["550e8400-e29b-41d4-a716-446655440003"], cards: [] }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ decks: [deckData], cards: [], reviewLogs: [] }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.conflicts?.decks).toContain("550e8400-e29b-41d4-a716-446655440003"); + }); + + it("validates deck schema", async () => { + const invalidDeck = { + id: "not-a-uuid", + name: "", + description: null, + newCardsPerDay: -1, + createdAt: "invalid-date", + updatedAt: "2024-01-01T00:00:00.000Z", + deletedAt: null, + }; + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ decks: [invalidDeck], cards: [], reviewLogs: [] }), + }); + + expect(res.status).toBe(400); + }); + + it("validates card schema", async () => { + const invalidCard = { + id: "card-uuid-123", + deckId: "not-a-uuid", + front: "", + back: "Answer", + state: 5, // Invalid state + due: "2024-01-01T00:00:00.000Z", + stability: -1, // Invalid + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + deletedAt: null, + }; + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ decks: [], cards: [invalidCard], reviewLogs: [] }), + }); + + expect(res.status).toBe(400); + }); + + it("validates review log schema", async () => { + const invalidLog = { + id: "log-uuid-123", + cardId: "card-uuid-123", + rating: 5, // Invalid rating (must be 1-4) + state: 0, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: "2024-01-01T00:00:00.000Z", + durationMs: 5000, + }; + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decks: [], + cards: [], + reviewLogs: [invalidLog], + }), + }); + + expect(res.status).toBe(400); + }); + + it("pushes multiple entities at once", async () => { + const deckData = { + id: "550e8400-e29b-41d4-a716-446655440004", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + deletedAt: null, + }; + + const cardData = { + id: "550e8400-e29b-41d4-a716-446655440005", + deckId: "550e8400-e29b-41d4-a716-446655440004", + front: "Q", + back: "A", + state: 0, + due: "2024-01-01T00:00:00.000Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + deletedAt: null, + }; + + const reviewLogData = { + id: "550e8400-e29b-41d4-a716-446655440006", + cardId: "550e8400-e29b-41d4-a716-446655440005", + rating: 3, + state: 0, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: "2024-01-01T00:00:00.000Z", + durationMs: null, + }; + + const mockResult: SyncPushResult = { + decks: [{ id: "550e8400-e29b-41d4-a716-446655440004", syncVersion: 1 }], + cards: [{ id: "550e8400-e29b-41d4-a716-446655440005", syncVersion: 1 }], + reviewLogs: [{ id: "550e8400-e29b-41d4-a716-446655440006", syncVersion: 1 }], + conflicts: { decks: [], cards: [] }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decks: [deckData], + cards: [cardData], + reviewLogs: [reviewLogData], + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.decks).toHaveLength(1); + expect(body.cards).toHaveLength(1); + expect(body.reviewLogs).toHaveLength(1); + }); + + it("handles soft-deleted entities", async () => { + const deletedDeck = { + id: "550e8400-e29b-41d4-a716-446655440007", + name: "Deleted Deck", + description: null, + newCardsPerDay: 20, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-02T00:00:00.000Z", + deletedAt: "2024-01-02T00:00:00.000Z", + }; + + const mockResult: SyncPushResult = { + decks: [{ id: "550e8400-e29b-41d4-a716-446655440007", syncVersion: 2 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decks: [deletedDeck], + cards: [], + reviewLogs: [], + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.decks).toHaveLength(1); + }); +}); diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts new file mode 100644 index 0000000..01f9bd0 --- /dev/null +++ b/src/server/routes/sync.ts @@ -0,0 +1,78 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import { authMiddleware, getAuthUser } from "../middleware/index.js"; +import { + type SyncPushData, + type SyncRepository, + syncRepository, +} from "../repositories/sync.js"; + +export interface SyncDependencies { + syncRepo: SyncRepository; +} + +const syncDeckSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(255), + description: z.string().nullable(), + newCardsPerDay: z.number().int().min(0).max(1000), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + deletedAt: z.string().datetime().nullable(), +}); + +const syncCardSchema = z.object({ + id: z.string().uuid(), + deckId: z.string().uuid(), + front: z.string().min(1), + back: z.string().min(1), + state: z.number().int().min(0).max(3), + due: z.string().datetime(), + stability: z.number().min(0), + difficulty: z.number().min(0), + elapsedDays: z.number().int().min(0), + scheduledDays: z.number().int().min(0), + reps: z.number().int().min(0), + lapses: z.number().int().min(0), + lastReview: z.string().datetime().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + deletedAt: z.string().datetime().nullable(), +}); + +const syncReviewLogSchema = z.object({ + id: z.string().uuid(), + cardId: z.string().uuid(), + rating: z.number().int().min(1).max(4), + state: z.number().int().min(0).max(3), + scheduledDays: z.number().int().min(0), + elapsedDays: z.number().int().min(0), + reviewedAt: z.string().datetime(), + durationMs: z.number().int().min(0).nullable(), +}); + +const syncPushSchema = z.object({ + decks: z.array(syncDeckSchema).default([]), + cards: z.array(syncCardSchema).default([]), + reviewLogs: z.array(syncReviewLogSchema).default([]), +}); + +export function createSyncRouter(deps: SyncDependencies) { + const { syncRepo } = deps; + + return new Hono() + .use("*", authMiddleware) + .post("/push", zValidator("json", syncPushSchema), async (c) => { + const user = getAuthUser(c); + const data = c.req.valid("json") as SyncPushData; + + const result = await syncRepo.pushChanges(user.id, data); + + return c.json(result, 200); + }); +} + +export const sync = createSyncRouter({ + syncRepo: syncRepository, +}); |
