mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 17:05:48 +00:00
Add pretest script to run prisma generate before tests, ensuring the Prisma client is always generated in any environment. Fix verifyToken mocks across all test files to include valid:true matching the TokenVerificationResult discriminated union. Expand jsonwebtoken mock to include JsonWebTokenError/TokenExpiredError stubs needed by auth.ts. Add $queryRaw to transaction mock in sync-fixes tests.
576 lines
20 KiB
TypeScript
576 lines
20 KiB
TypeScript
/**
|
|
* Tests for sync system fixes (Issues 1-6)
|
|
*
|
|
* These tests verify the fixes implemented for the identified sync issues:
|
|
* - Issue 1: Request deduplication with piggybacking
|
|
* - Issue 3: Encrypted snapshot uploads
|
|
* - Issue 4: Encrypted ops blocking restore endpoint
|
|
* - Issue 5: Dedup before quota check
|
|
* - Issue 6: Vector-clock EQUAL from different client
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import Fastify, { FastifyInstance } from 'fastify';
|
|
import { uuidv7 } from 'uuidv7';
|
|
|
|
// Store for test data
|
|
let testOperations: Map<string, any>;
|
|
let serverSeqCounter: number;
|
|
let requestCache: Map<string, any>;
|
|
|
|
// Mock the database module with Prisma mocks
|
|
vi.mock('../src/db', () => {
|
|
return {
|
|
prisma: {
|
|
$transaction: vi.fn().mockImplementation(async (callback: any) => {
|
|
const tx = {
|
|
operation: {
|
|
create: vi.fn().mockImplementation(async (args: any) => {
|
|
serverSeqCounter++;
|
|
const op = {
|
|
...args.data,
|
|
serverSeq: serverSeqCounter,
|
|
receivedAt: BigInt(Date.now()),
|
|
};
|
|
testOperations.set(args.data.id, op);
|
|
return op;
|
|
}),
|
|
findFirst: vi.fn().mockImplementation(async (args: any) => {
|
|
// Find by ID
|
|
if (args.where?.id) {
|
|
return testOperations.get(args.where.id) || null;
|
|
}
|
|
// Find full-state operation
|
|
if (args.where?.opType?.in) {
|
|
for (const op of Array.from(testOperations.values()).reverse()) {
|
|
if (
|
|
args.where.opType.in.includes(op.opType) &&
|
|
args.where.userId === op.userId
|
|
) {
|
|
return op;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}),
|
|
findMany: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = Array.from(testOperations.values());
|
|
return ops
|
|
.filter((op) => {
|
|
if (args.where?.userId !== undefined && args.where.userId !== op.userId)
|
|
return false;
|
|
if (
|
|
args.where?.serverSeq?.gt !== undefined &&
|
|
op.serverSeq <= args.where.serverSeq.gt
|
|
)
|
|
return false;
|
|
if (
|
|
args.where?.clientId?.not &&
|
|
op.clientId === args.where.clientId.not
|
|
)
|
|
return false;
|
|
return true;
|
|
})
|
|
.sort((a, b) => a.serverSeq - b.serverSeq)
|
|
.slice(0, args.take || 500);
|
|
}),
|
|
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
if (args.where?.id) {
|
|
return testOperations.get(args.where.id) || null;
|
|
}
|
|
return null;
|
|
}),
|
|
},
|
|
userSyncState: {
|
|
findUnique: vi.fn().mockResolvedValue({ lastSeq: serverSeqCounter }),
|
|
upsert: vi.fn().mockResolvedValue({}),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
syncDevice: {
|
|
upsert: vi.fn().mockResolvedValue({}),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
},
|
|
$queryRaw: vi.fn().mockResolvedValue([]),
|
|
};
|
|
return callback(tx);
|
|
}),
|
|
operation: {
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = Array.from(testOperations.values());
|
|
return ops
|
|
.filter((op) => {
|
|
if (args.where?.userId !== undefined && args.where.userId !== op.userId)
|
|
return false;
|
|
if (
|
|
args.where?.serverSeq?.gt !== undefined &&
|
|
op.serverSeq <= args.where.serverSeq.gt
|
|
)
|
|
return false;
|
|
if (args.where?.clientId?.not && op.clientId === args.where.clientId.not)
|
|
return false;
|
|
return true;
|
|
})
|
|
.sort((a, b) => a.serverSeq - b.serverSeq)
|
|
.slice(0, args.take || 500);
|
|
}),
|
|
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
if (args.where?.id) {
|
|
return testOperations.get(args.where.id) || null;
|
|
}
|
|
return null;
|
|
}),
|
|
},
|
|
userSyncState: {
|
|
findUnique: vi.fn().mockImplementation(async () => ({
|
|
lastSeq: serverSeqCounter,
|
|
})),
|
|
upsert: vi.fn().mockResolvedValue({}),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
syncDevice: {
|
|
upsert: vi.fn().mockResolvedValue({}),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
user: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: 1,
|
|
storageQuotaBytes: BigInt(100 * 1024 * 1024),
|
|
storageUsedBytes: BigInt(0),
|
|
}),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
$queryRaw: vi.fn().mockResolvedValue([{ total: BigInt(0) }]),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Mock auth
|
|
vi.mock('../src/auth', () => ({
|
|
verifyToken: vi
|
|
.fn()
|
|
.mockResolvedValue({ valid: true, userId: 1, email: 'test@test.com' }),
|
|
}));
|
|
|
|
// Import after mocking
|
|
import { syncRoutes } from '../src/sync/sync.routes';
|
|
import { initSyncService, getSyncService } from '../src/sync/sync.service';
|
|
|
|
// Helper to create operation
|
|
const createOp = (
|
|
clientId: string,
|
|
overrides: Partial<{
|
|
id: string;
|
|
actionType: string;
|
|
opType: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
payload: unknown;
|
|
vectorClock: Record<string, number>;
|
|
timestamp: number;
|
|
schemaVersion: number;
|
|
}> = {},
|
|
) => ({
|
|
id: uuidv7(),
|
|
clientId,
|
|
actionType: 'ADD_TASK',
|
|
opType: 'CRT',
|
|
entityType: 'TASK',
|
|
entityId: 'task-1',
|
|
payload: { title: 'Test Task' },
|
|
vectorClock: {},
|
|
timestamp: Date.now(),
|
|
schemaVersion: 1,
|
|
...overrides,
|
|
});
|
|
|
|
describe('Sync System Fixes', () => {
|
|
let app: FastifyInstance;
|
|
const authToken = 'mock-token';
|
|
|
|
beforeEach(async () => {
|
|
// Reset test state
|
|
testOperations = new Map();
|
|
serverSeqCounter = 0;
|
|
requestCache = new Map();
|
|
vi.clearAllMocks();
|
|
|
|
// Initialize service
|
|
initSyncService();
|
|
|
|
// Create Fastify app
|
|
app = Fastify();
|
|
await app.register(syncRoutes, { prefix: '/api/sync' });
|
|
await app.ready();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
// =============================================================================
|
|
// Issue 1: Request deduplication with piggybacking
|
|
// =============================================================================
|
|
describe('Issue 1: Request deduplication with piggybacking', () => {
|
|
it('should return fresh newOps on deduplicated request', async () => {
|
|
const clientA = 'client-a';
|
|
const clientB = 'client-b';
|
|
const requestId = 'req-123';
|
|
|
|
// Step 1: Client A uploads with requestId
|
|
const firstResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientA, { entityId: 'task-a' })],
|
|
clientId: clientA,
|
|
lastKnownServerSeq: 0,
|
|
requestId,
|
|
},
|
|
});
|
|
expect(firstResponse.statusCode).toBe(200);
|
|
const firstBody = firstResponse.json();
|
|
expect(firstBody.results[0].accepted).toBe(true);
|
|
const clientASeq = firstBody.latestSeq;
|
|
|
|
// Step 2: Client B uploads ops
|
|
const clientBResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientB, { entityId: 'task-b' })],
|
|
clientId: clientB,
|
|
},
|
|
});
|
|
expect(clientBResponse.statusCode).toBe(200);
|
|
|
|
// Step 3: Client A retries with same requestId but updated lastKnownServerSeq
|
|
const retryResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientA, { entityId: 'task-a' })],
|
|
clientId: clientA,
|
|
lastKnownServerSeq: clientASeq,
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
expect(retryResponse.statusCode).toBe(200);
|
|
const retryBody = retryResponse.json();
|
|
|
|
// Should be deduplicated
|
|
expect(retryBody.deduplicated).toBe(true);
|
|
|
|
// CRITICAL: Should have piggybacked ops from client B
|
|
expect(retryBody.newOps).toBeDefined();
|
|
expect(retryBody.newOps.length).toBeGreaterThan(0);
|
|
expect(
|
|
retryBody.newOps.some(
|
|
(op: { op: { clientId: string } }) => op.op.clientId === clientB,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should use retry request lastKnownServerSeq, not original', async () => {
|
|
const clientA = 'client-a';
|
|
const clientB = 'client-b';
|
|
const requestId = 'req-456';
|
|
|
|
// Client A uploads
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientA, { entityId: 'task-1' })],
|
|
clientId: clientA,
|
|
lastKnownServerSeq: 0,
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
// Client B uploads
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientB, { entityId: 'task-2' })],
|
|
clientId: clientB,
|
|
},
|
|
});
|
|
|
|
// Client A retries with lastKnownServerSeq=0
|
|
const retryResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientA, { entityId: 'task-1' })],
|
|
clientId: clientA,
|
|
lastKnownServerSeq: 0,
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
const body = retryResponse.json();
|
|
expect(body.deduplicated).toBe(true);
|
|
// Should get client B's ops since lastKnownServerSeq=0
|
|
expect(body.newOps?.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Issue 3: Encrypted snapshot uploads
|
|
// =============================================================================
|
|
describe('Issue 3: Encrypted snapshot uploads', () => {
|
|
it('should store isPayloadEncrypted flag from snapshot upload', async () => {
|
|
const snapshotResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/snapshot',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
state: 'encrypted-string-here',
|
|
clientId: 'test-client',
|
|
reason: 'recovery',
|
|
vectorClock: { 'test-client': 1 },
|
|
schemaVersion: 1,
|
|
isPayloadEncrypted: true,
|
|
},
|
|
});
|
|
|
|
expect(snapshotResponse.statusCode).toBe(200);
|
|
const snapshotBody = snapshotResponse.json();
|
|
expect(snapshotBody.accepted).toBe(true);
|
|
|
|
// Download ops and verify the SYNC_IMPORT has isPayloadEncrypted
|
|
const downloadResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/sync/ops?sinceSeq=0',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
});
|
|
|
|
const downloadBody = downloadResponse.json();
|
|
const syncImportOp = downloadBody.ops.find(
|
|
(op: { op: { opType: string } }) => op.op.opType === 'SYNC_IMPORT',
|
|
);
|
|
expect(syncImportOp).toBeDefined();
|
|
expect(syncImportOp.op.isPayloadEncrypted).toBe(true);
|
|
});
|
|
|
|
it('should default isPayloadEncrypted to false when not provided', async () => {
|
|
const snapshotResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/snapshot',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
state: { tasks: [] },
|
|
clientId: 'test-client',
|
|
reason: 'initial',
|
|
vectorClock: { 'test-client': 1 },
|
|
schemaVersion: 1,
|
|
},
|
|
});
|
|
|
|
expect(snapshotResponse.statusCode).toBe(200);
|
|
|
|
// Download and verify default
|
|
const downloadResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/sync/ops?sinceSeq=0',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
});
|
|
|
|
const downloadBody = downloadResponse.json();
|
|
const syncImportOp = downloadBody.ops.find(
|
|
(op: { op: { opType: string } }) => op.op.opType === 'SYNC_IMPORT',
|
|
);
|
|
expect(syncImportOp.op.isPayloadEncrypted).toBe(false);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Issue 4: Encrypted ops blocking restore
|
|
// NOTE: These tests require integration tests against real database
|
|
// The mock layer doesn't fully support the restore endpoint behavior.
|
|
// See tests/integration/ for full restore tests.
|
|
// =============================================================================
|
|
describe('Issue 4: Encrypted ops blocking restore', () => {
|
|
it.skip('should reject restore when encrypted ops exist in range (requires integration test)', async () => {
|
|
// This test requires the real database to test the encrypted ops check
|
|
// in generateSnapshotAtSeq. The mock doesn't implement operation.count.
|
|
// See integration tests for full coverage.
|
|
});
|
|
|
|
it.skip('should allow restore when no encrypted ops exist (requires integration test)', async () => {
|
|
// This test requires the real database to test restore functionality.
|
|
// See integration tests for full coverage.
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Issue 5: Dedup before quota check
|
|
// Tests that deduplication happens before quota check to allow retries
|
|
// =============================================================================
|
|
describe('Issue 5: Dedup before quota check', () => {
|
|
it('should return cached results even when quota would be exceeded on retry', async () => {
|
|
const clientId = uuidv7();
|
|
const requestId = uuidv7();
|
|
|
|
// First request with requestId
|
|
const firstResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientId, { entityId: 'task-1' })],
|
|
clientId,
|
|
lastKnownServerSeq: 0,
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
expect(firstResponse.statusCode).toBe(200);
|
|
const firstBody = firstResponse.json();
|
|
expect(firstBody.results[0].accepted).toBe(true);
|
|
|
|
// Second request with same requestId (retry) should return cached results
|
|
// even if the real quota check would fail (because dedup runs first)
|
|
const retryResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp(clientId, { entityId: 'task-1' })],
|
|
clientId,
|
|
lastKnownServerSeq: 0,
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
expect(retryResponse.statusCode).toBe(200);
|
|
const retryBody = retryResponse.json();
|
|
expect(retryBody.deduplicated).toBe(true);
|
|
expect(retryBody.results[0].accepted).toBe(true);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Issue 7: serverTime in download response
|
|
// =============================================================================
|
|
describe('Issue 7: serverTime in download response', () => {
|
|
it('should include serverTime in download response for clock drift detection', async () => {
|
|
// First upload an operation
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [createOp('test-client', { entityId: 'task-1' })],
|
|
clientId: 'test-client',
|
|
},
|
|
});
|
|
|
|
const beforeTime = Date.now();
|
|
const downloadResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/sync/ops?sinceSeq=0',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
});
|
|
const afterTime = Date.now();
|
|
|
|
expect(downloadResponse.statusCode).toBe(200);
|
|
const body = downloadResponse.json();
|
|
|
|
// serverTime should be present and within expected range
|
|
expect(body.serverTime).toBeDefined();
|
|
expect(typeof body.serverTime).toBe('number');
|
|
expect(body.serverTime).toBeGreaterThanOrEqual(beforeTime);
|
|
expect(body.serverTime).toBeLessThanOrEqual(afterTime);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Issue 8: Cleanup should return affected userIds for storage update
|
|
// =============================================================================
|
|
describe('Issue 8: deleteOldSyncedOpsForAllUsers returns affectedUserIds', () => {
|
|
it('should return totalDeleted and affectedUserIds structure', async () => {
|
|
const syncService = getSyncService();
|
|
const cutoffTime = Date.now() - 50 * 24 * 60 * 60 * 1000; // 50 days ago
|
|
|
|
// Call the method - with default mock it returns empty since no users have snapshots
|
|
const result = await syncService.deleteOldSyncedOpsForAllUsers(cutoffTime);
|
|
|
|
// Verify return structure
|
|
expect(result).toHaveProperty('totalDeleted');
|
|
expect(result).toHaveProperty('affectedUserIds');
|
|
expect(typeof result.totalDeleted).toBe('number');
|
|
expect(Array.isArray(result.affectedUserIds)).toBe(true);
|
|
});
|
|
|
|
it('should return empty affectedUserIds when no operations deleted', async () => {
|
|
const syncService = getSyncService();
|
|
const cutoffTime = Date.now() - 50 * 24 * 60 * 60 * 1000;
|
|
|
|
const result = await syncService.deleteOldSyncedOpsForAllUsers(cutoffTime);
|
|
|
|
expect(result.totalDeleted).toBe(0);
|
|
expect(result.affectedUserIds).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Issue 6: Vector-clock EQUAL from different client
|
|
// NOTE: Conflict detection requires the mock to properly implement
|
|
// operation.findFirst to return existing operations. The current mock
|
|
// doesn't support this, so these tests are skipped in favor of
|
|
// integration tests.
|
|
// =============================================================================
|
|
describe('Issue 6: Vector-clock EQUAL from different client', () => {
|
|
it.skip('should reject EQUAL clocks from different clients as conflict (requires integration test)', async () => {
|
|
// This test requires the mock's operation.findFirst to return
|
|
// the existing operation for conflict detection.
|
|
// See integration tests for full coverage.
|
|
});
|
|
|
|
it('should allow operations when no prior operation exists', async () => {
|
|
const clientA = uuidv7();
|
|
const entityId = 'task-' + uuidv7();
|
|
|
|
// Client A creates entity - should succeed since no prior op exists
|
|
const response1 = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sync/ops',
|
|
headers: { authorization: `Bearer ${authToken}` },
|
|
payload: {
|
|
ops: [
|
|
{
|
|
id: uuidv7(),
|
|
clientId: clientA,
|
|
actionType: 'AddTask',
|
|
opType: 'CRT',
|
|
entityType: 'TASK',
|
|
entityId,
|
|
payload: { title: 'Task' },
|
|
vectorClock: { [clientA]: 1 },
|
|
timestamp: Date.now(),
|
|
schemaVersion: 1,
|
|
},
|
|
],
|
|
clientId: clientA,
|
|
lastKnownServerSeq: 0,
|
|
},
|
|
});
|
|
|
|
expect(response1.statusCode).toBe(200);
|
|
expect(response1.json().results[0].accepted).toBe(true);
|
|
});
|
|
});
|
|
});
|