super-productivity/packages/super-sync-server/tests/integration/multi-client-sync.integration.spec.ts
Johannes Millan da8e509972 refactor(sync): unify JWT expiry to 365 days for all auth methods
Replace separate JWT_EXPIRY_MAGIC_LINK (365d) and JWT_EXPIRY_PASSKEY (7d)
constants with a single JWT_EXPIRY (365d). The auth method only matters
during login — once a JWT is issued, it represents a verified session
regardless of how the user authenticated.
2026-02-16 18:44:27 +01:00

1278 lines
45 KiB
TypeScript

/**
* Multi-Client Sync Integration Tests
*
* These tests simulate multiple clients syncing through the server
* to verify the complete sync protocol works correctly.
*
* No Playwright/browser needed - uses Fastify's inject() for HTTP simulation.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { FastifyInstance } from 'fastify';
import { initDb, getDb } from '../../src/db';
import { initSyncService, getSyncService } from '../../src/sync/sync.service';
import { syncRoutes } from '../../src/sync/sync.routes';
import { uuidv7 } from 'uuidv7';
import * as jwt from 'jsonwebtoken';
import {
Operation,
ServerOperation,
UploadOpsResponse,
DownloadOpsResponse,
SnapshotResponse,
} from '../../src/sync/sync.types';
const JWT_SECRET = 'super-sync-dev-secret-do-not-use-in-production';
/**
* Simulates a sync client with its own state tracking
*/
// Counter for unique client IDs within a test run
let clientCounter = 0;
class SimulatedClient {
readonly clientId: string;
readonly userId: number;
readonly authToken: string;
private app: FastifyInstance;
// Client-side state tracking
private lastKnownSeq = 0;
private appliedOps: ServerOperation[] = [];
private pendingOps: Operation[] = [];
private vectorClock: Record<string, number> = {};
constructor(app: FastifyInstance, userId: number, clientId?: string) {
this.app = app;
this.userId = userId;
// Use incrementing counter for unique client IDs
this.clientId = clientId || `client-${++clientCounter}-${uuidv7().slice(0, 8)}`;
this.authToken = jwt.sign({ userId, email: `user${userId}@test.com` }, JWT_SECRET, {
expiresIn: '365d',
});
}
/** Creates an operation with proper vector clock increment */
createOp(
opType: 'CRT' | 'UPD' | 'DEL' | 'MOV',
entityType: string,
entityId: string,
payload: unknown,
actionType = 'TEST_ACTION',
): Operation {
// Increment our own vector clock entry
this.vectorClock[this.clientId] = (this.vectorClock[this.clientId] || 0) + 1;
const op: Operation = {
id: uuidv7(),
clientId: this.clientId,
actionType,
opType,
entityType,
entityId,
payload,
vectorClock: { ...this.vectorClock },
timestamp: Date.now(),
schemaVersion: 1,
};
this.pendingOps.push(op);
return op;
}
/** Uploads pending operations to server */
async upload(): Promise<UploadOpsResponse> {
if (this.pendingOps.length === 0) {
throw new Error('No pending ops to upload');
}
const response = await this.app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: { authorization: `Bearer ${this.authToken}` },
payload: {
ops: this.pendingOps,
clientId: this.clientId,
lastKnownServerSeq: this.lastKnownSeq,
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as UploadOpsResponse;
// Update our sequence tracking
this.lastKnownSeq = body.latestSeq;
// Clear pending ops that were accepted
const acceptedIds = new Set(
body.results.filter((r) => r.accepted).map((r) => r.opId),
);
this.pendingOps = this.pendingOps.filter((op) => !acceptedIds.has(op.id));
// Process any piggybacked ops
if (body.newOps && body.newOps.length > 0) {
this.applyRemoteOps(body.newOps);
}
return body;
}
/** Downloads operations from server since lastKnownSeq */
async download(excludeSelf = true): Promise<DownloadOpsResponse> {
const query = new URLSearchParams({
sinceSeq: String(this.lastKnownSeq),
limit: '500',
});
if (excludeSelf) {
query.set('excludeClient', this.clientId);
}
const response = await this.app.inject({
method: 'GET',
url: `/api/sync/ops?${query.toString()}`,
headers: { authorization: `Bearer ${this.authToken}` },
});
expect(response.statusCode).toBe(200);
const body = response.json() as DownloadOpsResponse;
// Apply remote ops
if (body.ops.length > 0) {
this.applyRemoteOps(body.ops);
}
// Update sequence
this.lastKnownSeq = body.latestSeq;
return body;
}
/** Downloads all pages until hasMore=false */
async downloadAll(excludeSelf = true): Promise<ServerOperation[]> {
const allOps: ServerOperation[] = [];
let hasMore = true;
while (hasMore) {
const result = await this.download(excludeSelf);
allOps.push(...result.ops);
hasMore = result.hasMore;
}
return allOps;
}
/** Gets snapshot from server */
async getSnapshot(): Promise<SnapshotResponse> {
const response = await this.app.inject({
method: 'GET',
url: '/api/sync/snapshot',
headers: { authorization: `Bearer ${this.authToken}` },
});
expect(response.statusCode).toBe(200);
const body = response.json() as SnapshotResponse;
// Update our sequence to snapshot's seq
this.lastKnownSeq = body.serverSeq;
return body;
}
/** Applies remote operations to local state */
private applyRemoteOps(ops: ServerOperation[]): void {
for (const serverOp of ops) {
// Skip if already applied
if (this.appliedOps.some((o) => o.op.id === serverOp.op.id)) {
continue;
}
this.appliedOps.push(serverOp);
// Merge vector clock
for (const [clientId, seq] of Object.entries(serverOp.op.vectorClock)) {
this.vectorClock[clientId] = Math.max(this.vectorClock[clientId] || 0, seq);
}
}
}
// Getters for test assertions
get appliedOperations(): ServerOperation[] {
return [...this.appliedOps];
}
get currentVectorClock(): Record<string, number> {
return { ...this.vectorClock };
}
get currentSeq(): number {
return this.lastKnownSeq;
}
get hasPendingOps(): boolean {
return this.pendingOps.length > 0;
}
}
describe('Multi-Client Sync Integration', () => {
let app: FastifyInstance;
const userId = 1;
beforeEach(async () => {
initDb('./data', true); // In-memory DB
const db = getDb();
db.prepare(
`INSERT INTO users (id, email, password_hash, is_verified, created_at)
VALUES (?, 'test@test.com', 'hash', 1, ?)`,
).run(userId, Date.now());
initSyncService();
app = Fastify();
await app.register(syncRoutes, { prefix: '/api/sync' });
await app.ready();
});
afterEach(async () => {
await app.close();
});
describe('Basic Sync Round-Trip', () => {
it('should sync single operation from Client A to Client B', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
// Client A creates a task
clientA.createOp('CRT', 'TASK', 'task-1', { title: 'Test Task' });
const uploadResult = await clientA.upload();
expect(uploadResult.results[0].accepted).toBe(true);
expect(uploadResult.results[0].serverSeq).toBe(1);
// Client B downloads
const downloadResult = await clientB.download();
expect(downloadResult.ops).toHaveLength(1);
expect(downloadResult.ops[0].op.entityId).toBe('task-1');
expect(downloadResult.ops[0].serverSeq).toBe(1);
// Verify both clients have the same operation
expect(clientB.appliedOperations).toHaveLength(1);
expect(clientB.appliedOperations[0].op.id).toBe(
clientA.appliedOperations[0]?.op.id || uploadResult.results[0].opId,
);
});
it('should sync multiple operations in correct order', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
// Client A creates multiple tasks
clientA.createOp('CRT', 'TASK', 'task-1', { title: 'Task 1' });
clientA.createOp('CRT', 'TASK', 'task-2', { title: 'Task 2' });
clientA.createOp('UPD', 'TASK', 'task-1', { title: 'Task 1 Updated' });
await clientA.upload();
// Client B downloads all
const downloadResult = await clientB.downloadAll();
expect(downloadResult).toHaveLength(3);
expect(downloadResult[0].serverSeq).toBe(1);
expect(downloadResult[1].serverSeq).toBe(2);
expect(downloadResult[2].serverSeq).toBe(3);
// Verify order matches creation order
expect(downloadResult[0].op.entityId).toBe('task-1');
expect(downloadResult[0].op.opType).toBe('CRT');
expect(downloadResult[1].op.entityId).toBe('task-2');
expect(downloadResult[2].op.entityId).toBe('task-1');
expect(downloadResult[2].op.opType).toBe('UPD');
});
it('should handle bidirectional sync between two clients', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
// Client A creates task-1
clientA.createOp('CRT', 'TASK', 'task-1', { title: 'From A' });
await clientA.upload();
// Client B creates task-2 (different entity - no conflict)
clientB.createOp('CRT', 'TASK', 'task-2', { title: 'From B' });
await clientB.upload();
// Both clients sync
await clientA.download();
await clientB.download();
// Both should have 2 operations
expect(clientA.appliedOperations).toHaveLength(1); // Only remote op (task-2)
expect(clientB.appliedOperations).toHaveLength(1); // Only remote op (task-1)
// Verify vector clocks include both clients
expect(Object.keys(clientA.currentVectorClock)).toContain(clientB.clientId);
expect(Object.keys(clientB.currentVectorClock)).toContain(clientA.clientId);
});
});
describe('Sequence Continuity', () => {
it('should assign sequential serverSeq without gaps', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
// Interleaved uploads from two clients
clientA.createOp('CRT', 'TASK', 'task-a1', { title: 'A1' });
await clientA.upload(); // seq 1
clientB.createOp('CRT', 'TASK', 'task-b1', { title: 'B1' });
await clientB.upload(); // seq 2
clientA.createOp('CRT', 'TASK', 'task-a2', { title: 'A2' });
await clientA.upload(); // seq 3
clientB.createOp('CRT', 'TASK', 'task-b2', { title: 'B2' });
await clientB.upload(); // seq 4
// Fresh client downloads all
const freshClient = new SimulatedClient(app, userId);
const allOps = await freshClient.downloadAll(false);
expect(allOps).toHaveLength(4);
expect(allOps.map((o) => o.serverSeq)).toEqual([1, 2, 3, 4]);
});
it('should paginate correctly with hasMore flag', async () => {
const client = new SimulatedClient(app, userId);
// Upload 5 operations
for (let i = 1; i <= 5; i++) {
client.createOp('CRT', 'TASK', `task-${i}`, { title: `Task ${i}` });
}
await client.upload();
// Fresh client downloads with limit=2
const freshClient = new SimulatedClient(app, userId);
const response = await app.inject({
method: 'GET',
url: '/api/sync/ops?sinceSeq=0&limit=2',
headers: { authorization: `Bearer ${freshClient.authToken}` },
});
const body = response.json() as DownloadOpsResponse;
expect(body.ops).toHaveLength(2);
expect(body.hasMore).toBe(true);
expect(body.latestSeq).toBe(5);
});
it('should return hasMore=false when all ops retrieved', async () => {
const client = new SimulatedClient(app, userId);
client.createOp('CRT', 'TASK', 'task-1', { title: 'Task 1' });
client.createOp('CRT', 'TASK', 'task-2', { title: 'Task 2' });
await client.upload();
const freshClient = new SimulatedClient(app, userId);
const response = await app.inject({
method: 'GET',
url: '/api/sync/ops?sinceSeq=0&limit=10',
headers: { authorization: `Bearer ${freshClient.authToken}` },
});
const body = response.json() as DownloadOpsResponse;
expect(body.ops).toHaveLength(2);
expect(body.hasMore).toBe(false);
});
});
describe('Piggybacked Operations', () => {
it('should include remote ops in upload response when lastKnownServerSeq provided', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
// Client A uploads first
clientA.createOp('CRT', 'TASK', 'task-a', { title: 'From A' });
await clientA.upload();
// Client B uploads with lastKnownServerSeq=0
clientB.createOp('CRT', 'TASK', 'task-b', { title: 'From B' });
const uploadResult = await clientB.upload();
// Client B should receive Client A's op piggybacked
expect(uploadResult.newOps).toBeDefined();
expect(uploadResult.newOps).toHaveLength(1);
expect(uploadResult.newOps![0].op.entityId).toBe('task-a');
});
it('should NOT include own ops in piggybacked response', async () => {
const client = new SimulatedClient(app, userId);
// Upload first op
client.createOp('CRT', 'TASK', 'task-1', { title: 'Task 1' });
await client.upload();
// Upload second op with lastKnownServerSeq from first upload
client.createOp('CRT', 'TASK', 'task-2', { title: 'Task 2' });
const result = await client.upload();
// Should NOT get own ops back - newOps is undefined when empty
expect(result.newOps ?? []).toHaveLength(0);
});
});
describe('Fresh Client Bootstrap', () => {
it('should allow fresh client to get snapshot after other clients uploaded', async () => {
const clientA = new SimulatedClient(app, userId);
// Client A uploads several operations
clientA.createOp('CRT', 'TASK', 'task-1', { title: 'Task 1' });
clientA.createOp('CRT', 'TASK', 'task-2', { title: 'Task 2' });
clientA.createOp('UPD', 'TASK', 'task-1', { title: 'Task 1 Updated' });
clientA.createOp('DEL', 'TASK', 'task-2', null);
await clientA.upload();
// Fresh client requests snapshot
const freshClient = new SimulatedClient(app, userId);
const snapshot = await freshClient.getSnapshot();
expect(snapshot.serverSeq).toBe(4);
expect(snapshot.state).toBeDefined();
// State should have task-1 (updated) but NOT task-2 (deleted)
const state = snapshot.state as Record<string, unknown>;
// Note: actual state structure depends on snapshot reconstruction logic
});
it('should allow fresh client to continue with ops after snapshot', async () => {
const clientA = new SimulatedClient(app, userId);
// Initial ops
clientA.createOp('CRT', 'TASK', 'task-1', { title: 'Task 1' });
await clientA.upload();
// Fresh client gets snapshot
const freshClient = new SimulatedClient(app, userId);
await freshClient.getSnapshot();
// Client A adds more ops
clientA.createOp('CRT', 'TASK', 'task-2', { title: 'Task 2' });
await clientA.upload();
// Fresh client downloads ops since snapshot (don't exclude any client)
const downloaded = await freshClient.download(false);
expect(downloaded.ops).toHaveLength(1);
expect(downloaded.ops[0].op.entityId).toBe('task-2');
expect(downloaded.ops[0].serverSeq).toBe(2);
});
/**
* Scenario: User installs app on new device, server has existing data from other devices.
*
* This tests the server-side of the fresh client bootstrap flow:
* 1. Existing device has uploaded operations (tasks, projects, etc.)
* 2. Fresh device connects and downloads all remote operations
* 3. Fresh device should receive complete state without needing to upload anything first
* 4. After sync, fresh client has same state as server (via ops, not genesis import)
*
* Client-side behavior (confirmation dialog, genesis import prevention) is handled
* by OperationLogSyncService.downloadRemoteOps() - see operation-log-sync.service.ts
*/
it('should provide complete state to fresh client joining existing sync group', async () => {
// === Setup: Existing device has been using the app ===
const existingDevice = new SimulatedClient(app, userId, 'desktop-device');
// Simulate realistic usage: create project, tasks, updates, deletions
existingDevice.createOp('CRT', 'PROJECT', 'proj-1', {
id: 'proj-1',
title: 'Work Project',
taskIds: [],
});
existingDevice.createOp('CRT', 'TASK', 'task-1', {
id: 'task-1',
title: 'First task',
projectId: 'proj-1',
});
existingDevice.createOp('CRT', 'TASK', 'task-2', {
id: 'task-2',
title: 'Second task',
projectId: 'proj-1',
});
existingDevice.createOp('UPD', 'TASK', 'task-1', {
id: 'task-1',
title: 'First task (completed)',
done: true,
});
existingDevice.createOp('CRT', 'TASK', 'task-3', {
id: 'task-3',
title: 'Third task',
projectId: 'proj-1',
});
existingDevice.createOp('DEL', 'TASK', 'task-2', null); // User deleted task-2
await existingDevice.upload();
expect(existingDevice.currentSeq).toBe(6);
// === Fresh client joins (new device installation) ===
const freshDevice = new SimulatedClient(app, userId, 'mobile-device');
// Fresh client has NO local state - it starts at seq 0
expect(freshDevice.currentSeq).toBe(0);
expect(freshDevice.appliedOperations).toHaveLength(0);
// Fresh client downloads all operations from server
// In real app: this triggers confirmation dialog first (client-side)
const downloadedOps = await freshDevice.downloadAll(false);
// === Verify: Fresh client receives ALL operations ===
expect(downloadedOps).toHaveLength(6);
// Verify operation order matches server sequence
expect(downloadedOps[0].serverSeq).toBe(1);
expect(downloadedOps[0].op.opType).toBe('CRT');
expect(downloadedOps[0].op.entityType).toBe('PROJECT');
expect(downloadedOps[1].serverSeq).toBe(2);
expect(downloadedOps[1].op.entityId).toBe('task-1');
expect(downloadedOps[1].op.opType).toBe('CRT');
expect(downloadedOps[3].serverSeq).toBe(4);
expect(downloadedOps[3].op.opType).toBe('UPD');
expect(downloadedOps[5].serverSeq).toBe(6);
expect(downloadedOps[5].op.opType).toBe('DEL');
expect(downloadedOps[5].op.entityId).toBe('task-2');
// Fresh client is now at same sequence as server
expect(freshDevice.currentSeq).toBe(6);
// Fresh client's vector clock includes existing device
expect(freshDevice.currentVectorClock['desktop-device']).toBe(6);
// === Verify: Both devices are in sync ===
// If existing device downloads now, it should get nothing new
const existingDownload = await existingDevice.downloadAll();
expect(existingDownload).toHaveLength(0);
// Both at same sequence
expect(freshDevice.currentSeq).toBe(existingDevice.currentSeq);
});
/**
* Scenario: Fresh client receives data via operations, NOT via snapshot upload.
*
* This verifies that a fresh client bootstraps by downloading ops (which the client
* then applies locally), rather than by uploading a genesis/initial state.
* The fresh client should NOT upload anything during initial sync.
*/
it('should bootstrap fresh client via download only (no upload required)', async () => {
const existingDevice = new SimulatedClient(app, userId, 'existing');
// Existing device has data
existingDevice.createOp('CRT', 'TASK', 'task-1', { title: 'Existing task' });
existingDevice.createOp('CRT', 'TAG', 'tag-1', { title: 'Existing tag' });
await existingDevice.upload();
// Fresh device joins
const freshDevice = new SimulatedClient(app, userId, 'fresh');
// Get server status BEFORE fresh client does anything
const statusBefore = await app.inject({
method: 'GET',
url: '/api/sync/status',
headers: { authorization: `Bearer ${freshDevice.authToken}` },
});
const beforeStatus = statusBefore.json();
expect(beforeStatus.latestSeq).toBe(2);
// Fresh client downloads (simulates what happens after user confirms dialog)
await freshDevice.downloadAll(false);
// Get server status AFTER fresh client synced
const statusAfter = await app.inject({
method: 'GET',
url: '/api/sync/status',
headers: { authorization: `Bearer ${freshDevice.authToken}` },
});
const afterStatus = statusAfter.json();
// Server sequence should be UNCHANGED - fresh client didn't upload anything
expect(afterStatus.latestSeq).toBe(2);
// Fresh client has the data locally now
expect(freshDevice.appliedOperations).toHaveLength(2);
expect(freshDevice.currentSeq).toBe(2);
});
/**
* Scenario: Fresh client can contribute after initial sync.
*
* After bootstrapping from remote data, the fresh client should be able to
* create and upload its own operations that sync back to other devices.
*/
it('should allow fresh client to contribute after initial bootstrap', async () => {
const existingDevice = new SimulatedClient(app, userId, 'existing');
// Existing device has data
existingDevice.createOp('CRT', 'PROJECT', 'proj-1', { title: 'Shared Project' });
await existingDevice.upload();
// Fresh device bootstraps
const freshDevice = new SimulatedClient(app, userId, 'fresh');
await freshDevice.downloadAll(false);
expect(freshDevice.currentSeq).toBe(1);
// Now fresh device creates new data
freshDevice.createOp('CRT', 'TASK', 'task-from-fresh', {
title: 'Task created on new device',
projectId: 'proj-1',
});
const uploadResult = await freshDevice.upload();
// Fresh device's op was accepted
expect(uploadResult.results[0].accepted).toBe(true);
expect(uploadResult.results[0].serverSeq).toBe(2);
// Existing device can now download fresh device's contribution
const existingDownload = await existingDevice.downloadAll();
expect(existingDownload).toHaveLength(1);
expect(existingDownload[0].op.entityId).toBe('task-from-fresh');
expect(existingDownload[0].op.clientId).toBe('fresh');
// Both devices now have consistent state
expect(freshDevice.currentSeq).toBe(2);
expect(existingDevice.currentSeq).toBe(2);
});
});
describe('Vector Clock Preservation', () => {
it('should preserve vector clock through upload/download round-trip', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
// Client A creates op with specific vector clock
const op = clientA.createOp('CRT', 'TASK', 'task-1', { title: 'Test' });
const originalVC = { ...op.vectorClock };
await clientA.upload();
// Client B downloads (don't exclude self since B hasn't uploaded)
await clientB.download(false);
// Verify vector clock is preserved
expect(clientB.appliedOperations).toHaveLength(1);
const downloadedOp = clientB.appliedOperations[0];
expect(downloadedOp.op.vectorClock).toEqual(originalVC);
});
it('should correctly merge vector clocks from multiple clients', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
const clientC = new SimulatedClient(app, userId);
// Each client uploads one op
clientA.createOp('CRT', 'TASK', 'task-a', { title: 'A' });
await clientA.upload();
clientB.createOp('CRT', 'TASK', 'task-b', { title: 'B' });
await clientB.upload();
clientC.createOp('CRT', 'TASK', 'task-c', { title: 'C' });
await clientC.upload();
// Client A syncs - should have all 3 clients in vector clock
await clientA.downloadAll();
const vcA = clientA.currentVectorClock;
expect(vcA[clientA.clientId]).toBe(1);
expect(vcA[clientB.clientId]).toBe(1);
expect(vcA[clientC.clientId]).toBe(1);
});
});
describe('Idempotency', () => {
it('should reject duplicate operation IDs', async () => {
const client = new SimulatedClient(app, userId);
const op = client.createOp('CRT', 'TASK', 'task-1', { title: 'Test' });
await client.upload();
// Try to upload same op ID again (simulate retry)
const response = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: { authorization: `Bearer ${client.authToken}` },
payload: {
ops: [op],
clientId: client.clientId,
},
});
const body = response.json() as UploadOpsResponse;
expect(body.results[0].accepted).toBe(false);
// Case-insensitive check for "duplicate"
expect(body.results[0].error?.toLowerCase()).toContain('duplicate');
});
it('should not duplicate ops in download after retry', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
clientA.createOp('CRT', 'TASK', 'task-1', { title: 'Test' });
await clientA.upload();
// Client B downloads (first time) - don't exclude self since B hasn't uploaded
await clientB.download(false);
expect(clientB.appliedOperations).toHaveLength(1);
// Client B downloads again (simulate network retry) - seq is now updated
// so it won't get the same ops again from the server
const secondDownload = await clientB.download(false);
expect(secondDownload.ops).toHaveLength(0);
// Should still only have 1 applied op total
expect(clientB.appliedOperations).toHaveLength(1);
});
it('should handle partial batch upload recovery (simulated network failure)', async () => {
// Scenario: Client uploads 5 ops, server accepts first 3, connection dies.
// Client retries with all 5 ops. Server should reject first 3 as duplicates,
// accept remaining 2.
const client = new SimulatedClient(app, userId);
// Create 5 operations manually (not using client.createOp to avoid auto-queuing)
const baseVectorClock: Record<string, number> = {};
const ops: Operation[] = [];
for (let i = 1; i <= 5; i++) {
baseVectorClock[client.clientId] = i;
ops.push({
id: uuidv7(),
clientId: client.clientId,
actionType: 'TEST_ACTION',
opType: 'CRT',
entityType: 'TASK',
entityId: `task-${i}`,
payload: { title: `Task ${i}` },
vectorClock: { ...baseVectorClock },
timestamp: Date.now() + i,
schemaVersion: 1,
});
}
// Simulate: First 3 ops reach server (response never reaches client)
const firstThreeOps = ops.slice(0, 3);
const firstResponse = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: { authorization: `Bearer ${client.authToken}` },
payload: { ops: firstThreeOps, clientId: client.clientId },
});
expect(firstResponse.statusCode).toBe(200);
const firstBody = firstResponse.json() as UploadOpsResponse;
expect(firstBody.results.filter((r) => r.accepted)).toHaveLength(3);
// Client retries with ALL 5 ops (doesn't know first 3 succeeded)
const retryResponse = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: { authorization: `Bearer ${client.authToken}` },
payload: { ops: ops, clientId: client.clientId },
});
expect(retryResponse.statusCode).toBe(200);
const retryBody = retryResponse.json() as UploadOpsResponse;
// First 3 should be rejected as duplicates
expect(retryBody.results[0].accepted).toBe(false);
expect(retryBody.results[0].error?.toLowerCase()).toContain('duplicate');
expect(retryBody.results[1].accepted).toBe(false);
expect(retryBody.results[2].accepted).toBe(false);
// Last 2 should be accepted
expect(retryBody.results[3].accepted).toBe(true);
expect(retryBody.results[4].accepted).toBe(true);
// Verify all 5 ops are now on server (no duplicates, correct order)
const downloadResponse = await app.inject({
method: 'GET',
url: '/api/sync/ops?sinceSeq=0',
headers: { authorization: `Bearer ${client.authToken}` },
});
expect(downloadResponse.statusCode).toBe(200);
const downloadBody = downloadResponse.json() as DownloadOpsResponse;
expect(downloadBody.ops).toHaveLength(5);
// Verify ops are in correct sequence order
const entityIds = downloadBody.ops.map((o) => o.op.entityId);
expect(entityIds).toEqual(['task-1', 'task-2', 'task-3', 'task-4', 'task-5']);
});
});
describe('Multi-User Isolation', () => {
it('should isolate operations between different users', async () => {
// Create second user
const db = getDb();
db.prepare(
`INSERT INTO users (id, email, password_hash, is_verified, created_at)
VALUES (?, 'user2@test.com', 'hash', 1, ?)`,
).run(2, Date.now());
const user1Client = new SimulatedClient(app, 1);
const user2Client = new SimulatedClient(app, 2);
// User 1 uploads
user1Client.createOp('CRT', 'TASK', 'task-1', { title: 'User 1 Task' });
await user1Client.upload();
// User 2 uploads
user2Client.createOp('CRT', 'TASK', 'task-2', { title: 'User 2 Task' });
await user2Client.upload();
// User 2 downloads - should NOT see User 1's ops
const user2Download = await user2Client.download();
expect(user2Download.ops).toHaveLength(0); // Already uploaded, excludeClient filters it
// User 1 downloads - should NOT see User 2's ops
const user1Download = await user1Client.download();
expect(user1Download.ops).toHaveLength(0);
// Verify sequence numbers are independent
expect(user1Client.currentSeq).toBe(1);
expect(user2Client.currentSeq).toBe(1);
});
});
describe('Concurrent Uploads', () => {
it('should handle simultaneous uploads from multiple clients', async () => {
const clients = Array.from({ length: 5 }, () => new SimulatedClient(app, userId));
// All clients create ops
clients.forEach((client, i) => {
client.createOp('CRT', 'TASK', `task-${i}`, { title: `Task ${i}` });
});
// All upload concurrently
const results = await Promise.all(clients.map((c) => c.upload()));
// Verify all accepted with unique sequential serverSeq
const allSeqs = results.flatMap((r) =>
r.results.filter((res) => res.accepted).map((res) => res.serverSeq),
);
expect(allSeqs).toHaveLength(5);
expect(new Set(allSeqs).size).toBe(5); // All unique
// Verify they form a contiguous sequence 1-5
const sorted = [...allSeqs].sort((a, b) => (a ?? 0) - (b ?? 0));
expect(sorted).toEqual([1, 2, 3, 4, 5]);
});
});
describe('Delete Operations', () => {
it('should properly handle CRT -> DEL sequence', async () => {
const clientA = new SimulatedClient(app, userId);
const clientB = new SimulatedClient(app, userId);
// Create then delete
clientA.createOp('CRT', 'TASK', 'task-1', { title: 'Will Delete' });
clientA.createOp('DEL', 'TASK', 'task-1', null);
await clientA.upload();
// Client B downloads (don't exclude any client since B hasn't uploaded anything)
const ops = await clientB.downloadAll(false);
expect(ops).toHaveLength(2);
expect(ops[0].op.opType).toBe('CRT');
expect(ops[1].op.opType).toBe('DEL');
});
});
describe('Error Cases', () => {
it('should clamp ops with timestamp too far in future', async () => {
const client = new SimulatedClient(app, userId);
const futureOp: Operation = {
id: uuidv7(),
clientId: client.clientId,
actionType: 'TEST',
opType: 'CRT',
entityType: 'TASK',
entityId: 'task-future',
payload: { title: 'Future' },
vectorClock: {},
timestamp: Date.now() + 120_000, // 2 min in future
schemaVersion: 1,
};
const response = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: { authorization: `Bearer ${client.authToken}` },
payload: {
ops: [futureOp],
clientId: client.clientId,
},
});
const body = response.json() as UploadOpsResponse;
// Future timestamps are clamped, not rejected (prevents silent data loss)
expect(body.results[0].accepted).toBe(true);
expect(body.results[0].serverSeq).toBeDefined();
});
it('should reject ops with invalid entity type', async () => {
const client = new SimulatedClient(app, userId);
const badOp: Operation = {
id: uuidv7(),
clientId: client.clientId,
actionType: 'TEST',
opType: 'CRT',
entityType: '', // Invalid empty
entityId: 'task-1',
payload: { title: 'Test' },
vectorClock: {},
timestamp: Date.now(),
schemaVersion: 1,
};
const response = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: { authorization: `Bearer ${client.authToken}` },
payload: {
ops: [badOp],
clientId: client.clientId,
},
});
// The server validates entityType and should reject
const body = response.json() as UploadOpsResponse;
// Either validation fails at schema level (no results) or at service level (results[0].accepted=false)
if (body.results && body.results.length > 0) {
expect(body.results[0].accepted).toBe(false);
} else {
// Schema validation failed - 400 status
expect(response.statusCode).toBe(400);
}
});
});
});
describe('Diagnostic: Sync Flow Verification', () => {
let app: FastifyInstance;
const userId = 1;
beforeEach(async () => {
initDb('./data', true);
const db = getDb();
db.prepare(
`INSERT INTO users (id, email, password_hash, is_verified, created_at)
VALUES (?, 'test@test.com', 'hash', 1, ?)`,
).run(userId, Date.now());
initSyncService();
app = Fastify();
await app.register(syncRoutes, { prefix: '/api/sync' });
await app.ready();
});
afterEach(async () => {
await app.close();
});
/**
* This test simulates a real-world sync scenario:
* 1. Client A (desktop) is active and creates tasks
* 2. Client B (mobile) was offline, comes online
* 3. Client B syncs and should see all of Client A's changes
* 4. Client B makes changes
* 5. Client A syncs and should see Client B's changes
*/
it('DIAGNOSTIC: Full real-world sync scenario', async () => {
console.log('\n=== Starting Full Sync Scenario ===\n');
const desktop = new SimulatedClient(app, userId, 'desktop-client');
const mobile = new SimulatedClient(app, userId, 'mobile-client');
// Step 1: Desktop creates initial tasks
console.log('Step 1: Desktop creating tasks...');
desktop.createOp('CRT', 'TASK', 'task-1', { title: 'Buy groceries' });
desktop.createOp('CRT', 'TASK', 'task-2', { title: 'Call mom' });
const desktopUpload1 = await desktop.upload();
console.log(' Desktop uploaded:', desktopUpload1.results.length, 'ops');
console.log(' Server seq:', desktopUpload1.latestSeq);
// Step 2: Mobile comes online and downloads
console.log('\nStep 2: Mobile syncing...');
const mobileDownload1 = await mobile.downloadAll();
console.log(' Mobile downloaded:', mobileDownload1.length, 'ops');
console.log(
' Mobile entities:',
mobileDownload1.map((o) => o.op.entityId),
);
expect(mobileDownload1).toHaveLength(2);
// Step 3: Mobile makes changes
console.log('\nStep 3: Mobile making changes...');
mobile.createOp('CRT', 'TASK', 'task-3', { title: 'Pick up kids' });
mobile.createOp('UPD', 'TASK', 'task-1', {
title: 'Buy groceries (done)',
done: true,
});
const mobileUpload = await mobile.upload();
console.log(' Mobile uploaded:', mobileUpload.results.length, 'ops');
console.log(' Server seq:', mobileUpload.latestSeq);
// Step 4: Desktop syncs
console.log('\nStep 4: Desktop syncing...');
const desktopDownload = await desktop.downloadAll();
console.log(' Desktop downloaded:', desktopDownload.length, 'ops');
console.log(
' Desktop entities:',
desktopDownload.map((o) => `${o.op.opType}:${o.op.entityId}`),
);
expect(desktopDownload).toHaveLength(2); // task-3 CRT + task-1 UPD
// Final state verification
console.log('\n=== Final State ===');
console.log('Desktop seq:', desktop.currentSeq);
console.log('Mobile seq:', mobile.currentSeq);
console.log('Desktop VC:', desktop.currentVectorClock);
console.log('Mobile VC:', mobile.currentVectorClock);
// Both should be at seq 4
expect(desktop.currentSeq).toBe(4);
expect(mobile.currentSeq).toBe(4);
// Both vector clocks should know about each other
expect(desktop.currentVectorClock['mobile-client']).toBeDefined();
expect(mobile.currentVectorClock['desktop-client']).toBeDefined();
console.log('\n=== Sync Scenario PASSED ===\n');
});
/**
* Diagnostic test to verify server-side conflict detection.
* Server now detects concurrent edits via vector clock comparison.
*/
it('DIAGNOSTIC: Concurrent edit detection', async () => {
console.log('\n=== Concurrent Edit Scenario ===\n');
const clientA = new SimulatedClient(app, userId, 'client-a');
const clientB = new SimulatedClient(app, userId, 'client-b');
// Both start from same baseline
clientA.createOp('CRT', 'TASK', 'shared-task', { title: 'Original' });
await clientA.upload();
// Client B downloads initial state (don't exclude since B hasn't uploaded yet)
await clientB.downloadAll(false);
console.log('Initial state - both clients have shared-task');
console.log(' Client A VC:', clientA.currentVectorClock);
console.log(' Client B VC:', clientB.currentVectorClock);
console.log(' Client A seq:', clientA.currentSeq);
console.log(' Client B seq:', clientB.currentSeq);
// Both edit the SAME task (this creates concurrent edits)
// After B downloads A's CRT op, B's VC becomes {client-a: 1}
// When A edits: A's VC becomes {client-a: 2}
// When B edits: B's VC becomes {client-a: 1, client-b: 1}
// These are CONCURRENT (neither > the other)
console.log('\nBoth clients editing same task concurrently...');
clientA.createOp('UPD', 'TASK', 'shared-task', { title: 'Edited by A' });
clientB.createOp('UPD', 'TASK', 'shared-task', { title: 'Edited by B' });
console.log(' Client A VC after edit:', clientA.currentVectorClock);
console.log(' Client B VC after edit:', clientB.currentVectorClock);
// Both upload - Client A uploads first, then B
const resultA = await clientA.upload();
const resultB = await clientB.upload();
console.log('\nUpload results:');
console.log(' Client A accepted:', resultA.results[0].accepted);
console.log(' Client B accepted:', resultB.results[0].accepted);
console.log(' Client B error:', resultB.results[0].error);
// Client A's edit is accepted (first to upload wins)
expect(resultA.results[0].accepted).toBe(true);
// Client B's edit is REJECTED as concurrent conflict
// Server detects: B's VC {client-a:1, client-b:1} vs server's latest for entity {client-a:2}
// B has client-a:1 < 2, so not GREATER_THAN → rejected as CONCURRENT
expect(resultB.results[0].accepted).toBe(false);
expect(resultB.results[0].errorCode).toBe('CONFLICT_CONCURRENT');
// Client B gets A's op piggybacked so it can resolve the conflict
expect(resultB.newOps).toHaveLength(1);
expect(resultB.newOps![0].op.payload).toEqual({ title: 'Edited by A' });
console.log('\n=== Concurrent Edit Scenario Complete ===');
console.log('NOTE: Server detects conflicts via vector clock comparison.');
console.log('Client must resolve and retry with merged vector clock.');
console.log('\n');
});
});
describe('Gzip Compressed Snapshot Integration', () => {
let app: FastifyInstance;
const userId = 1;
beforeEach(async () => {
initDb('./data', true);
const db = getDb();
db.prepare(
`INSERT INTO users (id, email, password_hash, is_verified, created_at)
VALUES (?, 'test@test.com', 'hash', 1, ?)`,
).run(userId, Date.now());
initSyncService();
app = Fastify();
await app.register(syncRoutes, { prefix: '/api/sync' });
await app.ready();
});
afterEach(async () => {
await app.close();
});
/**
* Integration test: Verify that gzip-compressed snapshot uploads work
* through the complete route handler and sync service.
*/
it('should accept gzip-compressed snapshot and sync to other clients', async () => {
const zlib = await import('zlib');
const { promisify } = await import('util');
const gzipAsync = promisify(zlib.gzip);
const clientA = new SimulatedClient(app, userId, 'desktop');
const clientB = new SimulatedClient(app, userId, 'mobile');
// Create a large payload (simulates backup import)
const largeState = {
tasks: Array.from({ length: 100 }, (_, i) => ({
id: `task-${i}`,
title: `Task number ${i} with some longer description text`,
done: i % 3 === 0,
projectId: 'proj-1',
})),
projects: [{ id: 'proj-1', title: 'Main Project', taskIds: [] }],
tags: [{ id: 'tag-1', name: 'Important' }],
};
const snapshotPayload = {
state: largeState,
clientId: clientA.clientId,
reason: 'recovery',
vectorClock: { [clientA.clientId]: 1 },
schemaVersion: 1,
};
const jsonPayload = JSON.stringify(snapshotPayload);
const compressedPayload = await gzipAsync(Buffer.from(jsonPayload));
console.log(
`Payload size: ${jsonPayload.length} bytes -> ${compressedPayload.length} bytes (${((compressedPayload.length / jsonPayload.length) * 100).toFixed(1)}%)`,
);
// Upload compressed snapshot
const response = await app.inject({
method: 'POST',
url: '/api/sync/snapshot',
headers: {
authorization: `Bearer ${clientA.authToken}`,
'content-type': 'application/json',
'content-encoding': 'gzip',
},
payload: compressedPayload,
});
expect(response.statusCode).toBe(200);
const result = response.json();
expect(result.accepted).toBe(true);
expect(result.serverSeq).toBeDefined();
console.log(`Snapshot accepted at serverSeq: ${result.serverSeq}`);
// Client B should be able to download the snapshot
const snapshotResponse = await app.inject({
method: 'GET',
url: '/api/sync/snapshot',
headers: { authorization: `Bearer ${clientB.authToken}` },
});
expect(snapshotResponse.statusCode).toBe(200);
const snapshot = snapshotResponse.json();
// Verify the state was correctly stored and retrieved
expect(snapshot.state).toBeDefined();
expect(snapshot.serverSeq).toBe(result.serverSeq);
console.log('Client B successfully retrieved snapshot from server');
});
/**
* Verify that an uncompressed snapshot still works (backwards compatibility).
*/
it('should accept uncompressed snapshot alongside compressed ones', async () => {
const zlib = await import('zlib');
const { promisify } = await import('util');
const gzipAsync = promisify(zlib.gzip);
const clientA = new SimulatedClient(app, userId, 'client-a');
const clientB = new SimulatedClient(app, userId, 'client-b');
// Client A uploads compressed
const payloadA = {
state: { tasks: [{ id: 'task-a', title: 'From A' }] },
clientId: clientA.clientId,
reason: 'initial',
vectorClock: { [clientA.clientId]: 1 },
schemaVersion: 1,
};
const compressedA = await gzipAsync(Buffer.from(JSON.stringify(payloadA)));
const responseA = await app.inject({
method: 'POST',
url: '/api/sync/snapshot',
headers: {
authorization: `Bearer ${clientA.authToken}`,
'content-type': 'application/json',
'content-encoding': 'gzip',
},
payload: compressedA,
});
expect(responseA.statusCode).toBe(200);
expect(responseA.json().accepted).toBe(true);
// Client B uploads uncompressed (no Content-Encoding header)
const payloadB = {
state: { tasks: [{ id: 'task-b', title: 'From B' }] },
clientId: clientB.clientId,
reason: 'initial',
vectorClock: { [clientB.clientId]: 1 },
schemaVersion: 1,
};
const responseB = await app.inject({
method: 'POST',
url: '/api/sync/snapshot',
headers: {
authorization: `Bearer ${clientB.authToken}`,
'content-type': 'application/json',
},
payload: payloadB,
});
expect(responseB.statusCode).toBe(200);
expect(responseB.json().accepted).toBe(true);
// Both should be at sequential server sequences
const seqA = responseA.json().serverSeq;
const seqB = responseB.json().serverSeq;
expect(seqB).toBe(seqA + 1);
console.log(`Mixed compression: A (gzip) at seq ${seqA}, B (plain) at seq ${seqB}`);
});
});