test(sync-server): add comprehensive edge case tests for snapshot skip

Add 11 additional edge case tests for the server-side snapshot skip
optimization:
- SYNC_IMPORT at seq 1
- sinceSeq exactly equal to snapshotSeq
- Empty ops when all before snapshot
- excludeClient interaction with snapshot detection
- Gap detection: intentional skip vs real gaps
- Pagination after snapshot
- Multiple snapshots (use latest)
- Empty server state
- sinceSeq slightly before snapshotSeq
- sinceSeq at exactly snapshotSeq-1

Total: 17 unit tests covering the optimization thoroughly.
This commit is contained in:
Johannes Millan 2025-12-17 11:42:32 +01:00
parent e36ba3f47d
commit e0786e4e2e

View file

@ -299,5 +299,524 @@ describe('Snapshot Skip Optimization', () => {
expect(result.latestSnapshotSeq).toBe(20);
});
// === Edge Cases ===
it('should handle SYNC_IMPORT at seq 1 (edge case)', async () => {
// Edge case: SYNC_IMPORT is the very first operation
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: 1 }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// effectiveSinceSeq should be 1 - 1 = 0, so query is serverSeq > 0
expect(args.where.serverSeq.gt).toBe(0);
return [
{
id: 'op-1',
serverSeq: 1,
clientId: 'client-a',
actionType: '[SP_ALL] Load(import) all data',
opType: 'SYNC_IMPORT',
entityType: 'ALL',
entityId: null,
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 1 }),
},
};
return callback(tx);
});
const result = await service.getOpsSinceWithSeq(userId, 0);
expect(result.latestSnapshotSeq).toBe(1);
expect(result.ops.length).toBe(1);
expect(result.ops[0].serverSeq).toBe(1);
});
it('should handle sinceSeq exactly equal to snapshotSeq (no skip needed)', async () => {
// Client is exactly at the snapshot seq - shouldn't skip further
const snapshotSeq = 50;
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: snapshotSeq }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// sinceSeq (50) >= snapshotSeq (50), so no skip
expect(args.where.serverSeq.gt).toBe(50);
return [
{
id: 'op-51',
serverSeq: 51,
clientId: 'client-a',
actionType: 'ADD_TASK',
opType: 'CRT',
entityType: 'TASK',
entityId: 'task-1',
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 51 }),
},
};
return callback(tx);
});
const result = await service.getOpsSinceWithSeq(userId, 50);
expect(result.latestSnapshotSeq).toBe(50);
expect(result.ops.length).toBe(1);
expect(result.ops[0].serverSeq).toBe(51);
});
it('should return empty ops when all operations are before the snapshot', async () => {
// Scenario: SYNC_IMPORT at seq 100, but no ops after it yet
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: 100 }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// Skip to seq 99, return only the SYNC_IMPORT itself
expect(args.where.serverSeq.gt).toBe(99);
return [
{
id: 'op-100',
serverSeq: 100,
clientId: 'client-a',
actionType: '[SP_ALL] Load(import) all data',
opType: 'SYNC_IMPORT',
entityType: 'ALL',
entityId: null,
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 100 }),
},
};
return callback(tx);
});
const result = await service.getOpsSinceWithSeq(userId, 0);
// Should return just the SYNC_IMPORT
expect(result.ops.length).toBe(1);
expect(result.ops[0].op.opType).toBe('SYNC_IMPORT');
expect(result.latestSnapshotSeq).toBe(100);
});
it('should handle excludeClient without affecting snapshot detection', async () => {
// IMPORTANT: The snapshot detection should NOT be affected by excludeClient
// But the returned ops WILL be filtered by excludeClient
const snapshotSeq = 50;
const importingClient = 'client-a';
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
// findFirst for snapshot does NOT use excludeClient filter
findFirst: vi.fn().mockResolvedValue({ serverSeq: snapshotSeq }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// Verify excludeClient is applied to the query
expect(args.where.clientId?.not).toBe(importingClient);
// Return ops from a DIFFERENT client (not excluded)
return [
{
id: 'op-51',
serverSeq: 51,
clientId: 'client-b', // Different client
actionType: 'ADD_TASK',
opType: 'CRT',
entityType: 'TASK',
entityId: 'task-1',
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 51 }),
},
};
return callback(tx);
});
// Client A created the SYNC_IMPORT, but downloads excluding own ops
const result = await service.getOpsSinceWithSeq(userId, 0, importingClient);
// Should still detect the snapshot (created by client-a)
expect(result.latestSnapshotSeq).toBe(50);
// But the SYNC_IMPORT itself is excluded from results (since client-a created it)
// Only client-b's op is returned
expect(result.ops.length).toBe(1);
expect(result.ops[0].op.clientId).toBe('client-b');
});
it('should NOT detect gap when skipping to snapshot causes apparent gap', async () => {
// When we skip from sinceSeq=0 to snapshotSeq=99, the first op returned is 100
// This should NOT trigger gap detection because we intentionally skipped
const snapshotSeq = 100;
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: snapshotSeq }),
findMany: vi.fn().mockResolvedValue([
{
id: 'op-100',
serverSeq: 100,
clientId: 'client-a',
actionType: '[SP_ALL] Load(import) all data',
opType: 'SYNC_IMPORT',
entityType: 'ALL',
entityId: null,
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
]),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 100 }),
},
};
return callback(tx);
});
const result = await service.getOpsSinceWithSeq(userId, 0);
// effectiveSinceSeq = 99, first op is 100, 100 > 99 + 1 = 100 is FALSE
// So no gap should be detected
expect(result.gapDetected).toBe(false);
});
it('should still detect real gaps after the snapshot', async () => {
// Scenario: Client has already synced past the snapshot (sinceSeq=52 > snapshotSeq=50)
// Ops 53-55 were purged, first available is 56
// This is a real gap that should be detected
const snapshotSeq = 50;
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: snapshotSeq }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// sinceSeq=52 > snapshotSeq=50, so no skip optimization
expect(args.where.serverSeq.gt).toBe(52);
return [
{
id: 'op-56',
serverSeq: 56, // Gap! Expected 53, got 56
clientId: 'client-a',
actionType: 'ADD_TASK',
opType: 'CRT',
entityType: 'TASK',
entityId: 'task-1',
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 56 } }), // ops before 56 purged
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 56 }),
},
};
return callback(tx);
});
// Client has synced up to seq 52, but ops 53-55 are missing
const result = await service.getOpsSinceWithSeq(userId, 52);
// sinceSeq=52, first op is 56, 56 > 52 + 1 = 53 is TRUE
// Gap should be detected
expect(result.gapDetected).toBe(true);
expect(result.latestSnapshotSeq).toBe(50);
});
it('should handle pagination correctly (second request after snapshot)', async () => {
// Scenario:
// 1. First request: sinceSeq=0, skips to snapshot at 100, gets ops 100-199
// 2. Second request: sinceSeq=199, should NOT skip (already past snapshot)
const snapshotSeq = 100;
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: snapshotSeq }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// Second request with sinceSeq=199 should NOT modify query
expect(args.where.serverSeq.gt).toBe(199);
return [
{
id: 'op-200',
serverSeq: 200,
clientId: 'client-a',
actionType: 'ADD_TASK',
opType: 'CRT',
entityType: 'TASK',
entityId: 'task-200',
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 200 }),
},
};
return callback(tx);
});
// Second request (sinceSeq=199 is after snapshot at 100)
const result = await service.getOpsSinceWithSeq(userId, 199);
expect(result.latestSnapshotSeq).toBe(100);
expect(result.ops.length).toBe(1);
expect(result.ops[0].serverSeq).toBe(200);
expect(result.gapDetected).toBe(false);
});
it('should use the LATEST snapshot when multiple full-state ops exist', async () => {
// Scenario: SYNC_IMPORT at 10, BACKUP_IMPORT at 50, REPAIR at 100
// Should skip to 100, not 10 or 50
const latestSnapshotSeq = 100;
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
// findFirst with orderBy: { serverSeq: 'desc' } returns the LATEST
findFirst: vi.fn().mockResolvedValue({ serverSeq: latestSnapshotSeq }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// Should skip to 99, not 9 or 49
expect(args.where.serverSeq.gt).toBe(99);
return [
{
id: 'op-100',
serverSeq: 100,
clientId: 'client-a',
actionType: '[SP_ALL] Load(import) all data',
opType: 'REPAIR',
entityType: 'ALL',
entityId: null,
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 100 }),
},
};
return callback(tx);
});
const result = await service.getOpsSinceWithSeq(userId, 0);
expect(result.latestSnapshotSeq).toBe(100);
});
it('should handle empty server state (no operations)', async () => {
// Fresh user with no operations at all
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue(null), // No snapshots
findMany: vi.fn().mockResolvedValue([]), // No ops
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: null } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 0 }),
},
};
return callback(tx);
});
const result = await service.getOpsSinceWithSeq(userId, 0);
expect(result.latestSnapshotSeq).toBeUndefined();
expect(result.ops.length).toBe(0);
expect(result.latestSeq).toBe(0);
expect(result.gapDetected).toBe(false);
});
it('should correctly skip when sinceSeq is slightly before snapshotSeq', async () => {
// Client at seq 95, snapshot at seq 100
// Should skip to 99, not stay at 95 (would miss ops 96-99, but they're superseded anyway)
const snapshotSeq = 100;
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: snapshotSeq }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// sinceSeq=95 < snapshotSeq=100, so skip to 99
expect(args.where.serverSeq.gt).toBe(99);
return [
{
id: 'op-100',
serverSeq: 100,
clientId: 'client-a',
actionType: '[SP_ALL] Load(import) all data',
opType: 'SYNC_IMPORT',
entityType: 'ALL',
entityId: null,
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
{
id: 'op-101',
serverSeq: 101,
clientId: 'client-a',
actionType: 'ADD_TASK',
opType: 'CRT',
entityType: 'TASK',
entityId: 'task-1',
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 101 }),
},
};
return callback(tx);
});
// Client has synced up to seq 95, slightly before snapshot at 100
const result = await service.getOpsSinceWithSeq(userId, 95);
expect(result.latestSnapshotSeq).toBe(100);
// Should return snapshot and subsequent ops, not ops 96-99
expect(result.ops.length).toBe(2);
expect(result.ops[0].serverSeq).toBe(100);
expect(result.ops[1].serverSeq).toBe(101);
expect(result.gapDetected).toBe(false); // No gap - intentional skip
});
it('should handle sinceSeq at exactly snapshotSeq minus 1', async () => {
// Edge case: sinceSeq = 99, snapshotSeq = 100
// sinceSeq < snapshotSeq, so effectiveSinceSeq = 99 (same as original!)
const snapshotSeq = 100;
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => {
const tx = {
operation: {
findFirst: vi.fn().mockResolvedValue({ serverSeq: snapshotSeq }),
findMany: vi.fn().mockImplementation(async (args: any) => {
// sinceSeq=99 < snapshotSeq=100, so effectiveSinceSeq = 99
// But original sinceSeq was also 99, so no change
expect(args.where.serverSeq.gt).toBe(99);
return [
{
id: 'op-100',
serverSeq: 100,
clientId: 'client-a',
actionType: '[SP_ALL] Load(import) all data',
opType: 'SYNC_IMPORT',
entityType: 'ALL',
entityId: null,
payload: {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(Date.now()),
parentOpId: null,
isPayloadEncrypted: false,
},
];
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: 100 }),
},
};
return callback(tx);
});
const result = await service.getOpsSinceWithSeq(userId, 99);
expect(result.latestSnapshotSeq).toBe(100);
expect(result.ops.length).toBe(1);
expect(result.ops[0].serverSeq).toBe(100);
});
});
});