mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-20 18:08:55 +00:00
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:
parent
e36ba3f47d
commit
e0786e4e2e
1 changed files with 519 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue