mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 17:05:48 +00:00
* chore(plugins): re-bundle document-mode and document Stage A path Reverts the unbundling fromb0cae69ffe. Stage 0 (gzip + throttle, shipped in84625be849) 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.
643 lines
22 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|