super-productivity/packages/super-sync-server/tests/sync-fixes.spec.ts
Johannes Millan 196e50b906
test: strengthen unit test assertions and revive disabled plugin specs (#7755)
* chore(plugins): re-bundle document-mode and document Stage A path

Reverts the unbundling from b0cae69ffe. Stage 0 (gzip + throttle, shipped
in 84625be849) handles size; document-mode remains opt-in per context, so
cross-context conflict risk is bounded. Stage A (keyed plugin-persistence
API, issue #7749) is the documented future path for closing the LWW gap
on different-context concurrent edits — picked up when conflicts are
observed in practice.

Design sketch with multi-reviewed phasing lives in
docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md. Predecessor
plan's "Future work" section now links to it.

* test(plugins): cover document-mode bundled load and PLUGIN_USER_DATA LWW

Follow-ups from the multi-review of 199e816479's re-bundling decision:

- E2E smoke test asserts document-mode appears in plugin management so
  a typo in BUNDLED_PLUGIN_PATHS fails loudly.
- Spec exercises PLUGIN_USER_DATA conflict resolution end-to-end, which
  previously relied on analogy to REMINDER (same array+null branch) but
  was never directly asserted after the migration off 'virtual'.
- Stage A plan risks: stale-editor-view gap surfaced by the review;
  PluginHooks.PERSISTED_DATA_UPDATE already exists in the API but is
  never dispatched host-side — wiring it is the path to a fix.
- background.ts: comment marks the known gap at the registerHook site.

* docs(plugins): plan for wiring PERSISTED_DATA_CHANGED hook

Designs the host-side wiring for the currently-dead
PluginHooks.PERSISTED_DATA_CHANGED so plugins can react to remote-driven
changes to their persisted data. Multi-reviewed twice; v4 trims scope to
host-only (no plugin adoption in this design) and preserves the
multi-review insights as seeds for the follow-up doc-mode adoption
tracked at issue #7752.

Implementation lands in a separate PR.

* test: strengthen unit test assertions and revive disabled plugin specs

Replace tautological assertions, setTimeout-without-expect patterns, and
placeholder `expect(true).toBe(true)` tests with real assertions across
~30 spec files. Revive 5 plugin spec files that were fully commented out
on master with live tests covering core behavior.

Production-side: extract pure helpers for testability:
- app.component: getBackgroundOverlayOpacity, getBackgroundImageBlur
- android-sync-bridge.effects: getSuperSyncCredentialBridgeCommand
- super-sync-server: export escapeHtml, SERVER_HELMET_CONFIG

Delete the always-skipped xdescribe placeholder
src/app/imex/sync/sync-fixes.spec.ts (412 LOC).
Rename operation-log-stress.spec.ts to .benchmark.ts to match its
header comment ("excluded from regular test runs").

No production behavior changes; no master commits reverted.
2026-05-23 18:31:53 +02:00

643 lines
22 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', () => {
const applySelect = (op: any, select?: Record<string, boolean>) => {
if (!op || !select) {
return op;
}
return Object.fromEntries(
Object.entries(select)
.filter(([, shouldSelect]) => shouldSelect)
.map(([key]) => [key, op[key]]),
);
};
const hasUniqueConflict = (row: any) =>
Array.from(testOperations.values()).some(
(op) =>
op.id === row.id ||
(op.userId === row.userId &&
row.serverSeq !== undefined &&
op.serverSeq === row.serverSeq),
);
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;
}),
createMany: vi.fn().mockImplementation(async (args: any) => {
const rows = Array.isArray(args.data) ? args.data : [args.data];
let count = 0;
for (const row of rows) {
if (hasUniqueConflict(row)) {
if (args.skipDuplicates) {
continue;
}
throw new Error('Unique constraint failed');
}
if (row.serverSeq === undefined) {
serverSeqCounter++;
}
testOperations.set(row.id, {
...row,
serverSeq: row.serverSeq ?? serverSeqCounter,
receivedAt: row.receivedAt ?? BigInt(Date.now()),
});
count++;
}
return { count };
}),
findFirst: vi.fn().mockImplementation(async (args: any) => {
// Find by ID
if (args.where?.id) {
return (
applySelect(testOperations.get(args.where.id), args.select) || 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 applySelect(op, args.select);
}
}
}
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);
}),
count: 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?.serverSeq?.lte !== undefined &&
op.serverSeq > args.where.serverSeq.lte
)
return false;
if (
args.where?.isPayloadEncrypted !== undefined &&
op.isPayloadEncrypted !== args.where.isPayloadEncrypted
)
return false;
return true;
}).length;
}),
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
findUnique: vi.fn().mockImplementation(async (args: any) => {
if (args.where?.id) {
return (
applySelect(testOperations.get(args.where.id), args.select) || null
);
}
return null;
}),
},
userSyncState: {
findUnique: vi.fn().mockResolvedValue({ lastSeq: serverSeqCounter }),
upsert: vi.fn().mockResolvedValue({}),
update: vi.fn().mockImplementation(async (args: any) => {
if (args.data?.lastSeq?.increment !== undefined) {
serverSeqCounter += args.data.lastSeq.increment;
} else if (args.data?.lastSeq?.decrement !== undefined) {
serverSeqCounter -= args.data.lastSeq.decrement;
}
return { lastSeq: serverSeqCounter };
}),
},
syncDevice: {
upsert: vi.fn().mockResolvedValue({}),
count: vi.fn().mockResolvedValue(1),
},
$queryRaw: vi.fn().mockResolvedValue([]),
// Upload transaction writes the storage counter atomically via $executeRaw.
$executeRaw: vi.fn().mockResolvedValue(0),
};
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 applySelect(testOperations.get(args.where.id), args.select) || null;
}
return null;
}),
},
userSyncState: {
findUnique: vi.fn().mockImplementation(async () => ({
lastSeq: serverSeqCounter,
})),
upsert: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: 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 cacheSnapshotSpy = vi.spyOn(getSyncService(), 'cacheSnapshot');
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);
expect(cacheSnapshotSpy).not.toHaveBeenCalled();
const serverSnapshotResponse = await app.inject({
method: 'GET',
url: '/api/sync/snapshot',
headers: { authorization: `Bearer ${authToken}` },
});
expect(serverSnapshotResponse.statusCode).toBe(400);
expect(serverSnapshotResponse.json().errorCode).toBe('ENCRYPTED_OPS_NOT_SUPPORTED');
// 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 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
// =============================================================================
describe('Issue 6: Vector-clock EQUAL from different client', () => {
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);
});
});
});