aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/routes/sync.test.ts
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:16:48 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:16:48 +0900
commitfe101104cdd50256d4ef5c61e1bf099ed2da68e3 (patch)
tree862d84bd685dcbea6fe1bb2fc02f1cad33049196 /src/server/routes/sync.test.ts
parentc086c8b35b6c6f0b0e2623e9b6421713a540941a (diff)
downloadkioku-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>
Diffstat (limited to 'src/server/routes/sync.test.ts')
-rw-r--r--src/server/routes/sync.test.ts441
1 files changed, 441 insertions, 0 deletions
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);
+ });
+});