mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-19 01:17:31 +00:00
- B7: replace leaky `Transaction rolled back: <raw Prisma msg>` per-op error with a generic 'Transaction failed - please retry'. The raw exception text is still logged via `Logger.error`, but never returned to the client (could leak SQL fragments, FK / column names). - B6: in `deleteOldestRestorePointAndOps`, measure the exact bytes for the deleted full-state op (SYNC_IMPORT / BACKUP_IMPORT / REPAIR) row via `pg_column_size` BEFORE deletion. Bounded to 0-1 rows per call, so it doesn't reintroduce the DoS from scanning every delta op. Delta ops keep the cheap `count * APPROX_BYTES_PER_OP` approximation; full-state payloads (up to 20MB) no longer undercount by ~20000x and leave the counter permanently low if reconcile fails. Docstring on APPROX_BYTES_PER_OP clarifies it is ONLY valid for delta cleanup. - I6: inside `deleteOldSyncedOpsForAllUsers`, call `storageQuotaService.markNeedsReconcile(userId)` for each user whose cleanup deleted rows. A crash mid-loop now leaves surviving users with a forced-reconcile marker so their next request self-heals, instead of waiting for the daily pass. TODO left for persisting the marker in a DB column for cross-restart / multi-instance safety. - B3 (service side): write `users.storage_used_bytes` atomically inside the upload `$transaction` using `$executeRaw` with the same GREATEST(...) clamp pattern as `storage-quota.service.ts`. Shared `computeOpStorageBytes` helper keeps the gate and the increment in agreement about per-op size. Clean-slate path SETs the counter to the new total rather than incrementing onto the just-reset row. - B2 companion: add `RequestDeduplicationService.clearForUser(userId)` and call it from both `deleteAllUserData` and the uploadOps clean-slate path so a retry from before the wipe cannot return cached results that reference now-deleted state.
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
/**
|
|
* Vitest test setup file.
|
|
*
|
|
* This file provides mock implementations for the legacy SQLite-based
|
|
* test infrastructure after the migration to Prisma.
|
|
*/
|
|
import { vi, beforeEach } from 'vitest';
|
|
|
|
// In-memory storage for test data
|
|
interface TestData {
|
|
users: Map<number, any>;
|
|
operations: Map<string, any>;
|
|
syncDevices: Map<string, any>;
|
|
userSyncStates: Map<number, any>;
|
|
}
|
|
|
|
let testData: TestData = {
|
|
users: new Map(),
|
|
operations: new Map(),
|
|
syncDevices: new Map(),
|
|
userSyncStates: new Map(),
|
|
};
|
|
|
|
let serverSeqCounter = 0;
|
|
|
|
// Mock better-sqlite3 style database interface
|
|
const createMockDb = () => {
|
|
const mockDb = {
|
|
prepare: (sql: string) => {
|
|
return {
|
|
run: (...args: any[]) => {
|
|
// Parse and execute the SQL statement
|
|
if (sql.includes('INSERT INTO users')) {
|
|
const [id, email] = args;
|
|
testData.users.set(id, { id, email, password_hash: 'hash', is_verified: 1 });
|
|
return { changes: 1 };
|
|
}
|
|
if (sql.includes('UPDATE operations SET received_at')) {
|
|
// Handle time-based updates for tests
|
|
return { changes: 1 };
|
|
}
|
|
if (sql.includes('UPDATE sync_devices SET last_seen_at')) {
|
|
return { changes: 1 };
|
|
}
|
|
return { changes: 0 };
|
|
},
|
|
get: (...args: any[]) => {
|
|
if (sql.includes('FROM sync_devices')) {
|
|
const [userId, clientId] = args;
|
|
return testData.syncDevices.get(`${userId}:${clientId}`);
|
|
}
|
|
return undefined;
|
|
},
|
|
all: (...args: any[]) => {
|
|
return [];
|
|
},
|
|
};
|
|
},
|
|
exec: (sql: string) => {
|
|
// For schema creation, no-op
|
|
},
|
|
transaction: (fn: () => void) => {
|
|
return () => fn();
|
|
},
|
|
};
|
|
return mockDb;
|
|
};
|
|
|
|
let mockDb: ReturnType<typeof createMockDb> | null = null;
|
|
|
|
// Export mock functions that match the old SQLite-based API
|
|
export const initDb = (dataPath: string, inMemory: boolean = false) => {
|
|
testData = {
|
|
users: new Map(),
|
|
operations: new Map(),
|
|
syncDevices: new Map(),
|
|
userSyncStates: new Map(),
|
|
};
|
|
serverSeqCounter = 0;
|
|
mockDb = createMockDb();
|
|
};
|
|
|
|
export const getDb = () => {
|
|
if (!mockDb) {
|
|
throw new Error('Database not initialized. Call initDb first.');
|
|
}
|
|
return mockDb;
|
|
};
|
|
|
|
// Mock the db module
|
|
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 matchesWhere = (op: any, where: any) => {
|
|
if (!where) {
|
|
return true;
|
|
}
|
|
|
|
if (where.userId !== undefined && op.userId !== where.userId) return false;
|
|
if (where.id !== undefined && op.id !== where.id) return false;
|
|
if (where.entityType !== undefined && op.entityType !== where.entityType) {
|
|
return false;
|
|
}
|
|
if (where.entityId !== undefined && op.entityId !== where.entityId) return false;
|
|
if (where.clientId !== undefined) {
|
|
if (typeof where.clientId === 'object' && where.clientId !== null) {
|
|
if (where.clientId.not !== undefined && op.clientId === where.clientId.not) {
|
|
return false;
|
|
}
|
|
} else if (op.clientId !== where.clientId) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (where.serverSeq?.gt !== undefined && op.serverSeq <= where.serverSeq.gt) {
|
|
return false;
|
|
}
|
|
if (where.serverSeq?.gte !== undefined && op.serverSeq < where.serverSeq.gte) {
|
|
return false;
|
|
}
|
|
if (where.serverSeq?.lt !== undefined && op.serverSeq >= where.serverSeq.lt) {
|
|
return false;
|
|
}
|
|
if (where.serverSeq?.lte !== undefined && op.serverSeq > where.serverSeq.lte) {
|
|
return false;
|
|
}
|
|
|
|
if (where.opType?.in && !where.opType.in.includes(op.opType)) {
|
|
return false;
|
|
}
|
|
if (typeof where.opType === 'string' && op.opType !== where.opType) {
|
|
return false;
|
|
}
|
|
if (
|
|
where.isPayloadEncrypted !== undefined &&
|
|
op.isPayloadEncrypted !== where.isPayloadEncrypted
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const sortOperations = (ops: any[], orderBy: any) => {
|
|
if (orderBy?.serverSeq === 'desc') {
|
|
return ops.sort((a, b) => b.serverSeq - a.serverSeq);
|
|
}
|
|
if (orderBy?.serverSeq === 'asc') {
|
|
return ops.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
}
|
|
return ops;
|
|
};
|
|
|
|
const hasOperationUniqueConflict = (row: any) =>
|
|
Array.from(testData.operations.values()).some(
|
|
(op) =>
|
|
op.id === row.id ||
|
|
(op.userId === row.userId &&
|
|
row.serverSeq !== undefined &&
|
|
op.serverSeq === row.serverSeq),
|
|
);
|
|
|
|
// Create Prisma mock with all needed operations
|
|
const prismaMock = {
|
|
$transaction: vi.fn().mockImplementation(async (callback: any) => {
|
|
// Create a transaction context
|
|
const tx = {
|
|
operation: {
|
|
create: vi.fn().mockImplementation(async (args: any) => {
|
|
serverSeqCounter++;
|
|
const op = {
|
|
...args.data,
|
|
serverSeq: serverSeqCounter,
|
|
receivedAt: BigInt(Date.now()),
|
|
};
|
|
testData.operations.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 (hasOperationUniqueConflict(row)) {
|
|
if (args.skipDuplicates) {
|
|
continue;
|
|
}
|
|
throw new Error('Unique constraint failed');
|
|
}
|
|
|
|
testData.operations.set(row.id, {
|
|
...row,
|
|
receivedAt: row.receivedAt ?? BigInt(Date.now()),
|
|
});
|
|
count++;
|
|
}
|
|
|
|
return { count };
|
|
}),
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
// Check if operation with given ID exists
|
|
return (
|
|
applySelect(testData.operations.get(args.where?.id), args.select) || null
|
|
);
|
|
}),
|
|
findFirst: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = sortOperations(
|
|
Array.from(testData.operations.values()).filter((op) =>
|
|
matchesWhere(op, args.where),
|
|
),
|
|
args.orderBy,
|
|
);
|
|
return applySelect(ops[0], args.select) || null;
|
|
}),
|
|
findMany: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = Array.from(testData.operations.values()).filter((op) =>
|
|
matchesWhere(op, args.where),
|
|
);
|
|
return sortOperations(ops, args.orderBy).map((op) =>
|
|
applySelect(op, args.select),
|
|
);
|
|
}),
|
|
count: vi.fn().mockImplementation(async (args: any) => {
|
|
return Array.from(testData.operations.values()).filter((op) =>
|
|
matchesWhere(op, args.where),
|
|
).length;
|
|
}),
|
|
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
userSyncState: {
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
return testData.userSyncStates.get(args.where.userId) || null;
|
|
}),
|
|
upsert: vi.fn().mockImplementation(async (args: any) => {
|
|
const existing = testData.userSyncStates.get(args.where.userId);
|
|
const result = existing
|
|
? { ...existing, ...args.update }
|
|
: { userId: args.where.userId, ...args.create };
|
|
testData.userSyncStates.set(args.where.userId, result);
|
|
return result;
|
|
}),
|
|
update: vi.fn().mockImplementation(async (args: any) => {
|
|
const existing = testData.userSyncStates.get(args.where.userId);
|
|
if (existing) {
|
|
const updated = { ...existing };
|
|
// Handle Prisma's increment syntax: { lastSeq: { increment: 1 } }
|
|
if (args.data?.lastSeq?.increment !== undefined) {
|
|
updated.lastSeq = (existing.lastSeq || 0) + args.data.lastSeq.increment;
|
|
} else if (args.data?.lastSeq?.decrement !== undefined) {
|
|
updated.lastSeq = (existing.lastSeq || 0) - args.data.lastSeq.decrement;
|
|
} else {
|
|
Object.assign(updated, args.data);
|
|
}
|
|
testData.userSyncStates.set(args.where.userId, updated);
|
|
return updated;
|
|
}
|
|
return null;
|
|
}),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
syncDevice: {
|
|
upsert: vi.fn().mockImplementation(async (args: any) => {
|
|
// Handle both key naming conventions (Prisma uses userId_clientId)
|
|
const compositeKey = args.where.userId_clientId || args.where.clientId_userId;
|
|
const key = `${compositeKey.userId}:${compositeKey.clientId}`;
|
|
const result = { ...args.create, ...args.update };
|
|
testData.syncDevices.set(key, result);
|
|
return result;
|
|
}),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
user: {
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
return testData.users.get(args.where.id) || null;
|
|
}),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
$queryRaw: vi.fn().mockResolvedValue([{ total: BigInt(0) }]),
|
|
// The upload transaction writes the storage counter atomically via
|
|
// $executeRaw to keep the data write and the counter delta in a single
|
|
// commit. Default mock is a no-op; specs that care about counter
|
|
// behaviour mock it explicitly.
|
|
$executeRaw: vi.fn().mockResolvedValue(0),
|
|
};
|
|
if (typeof callback === 'function') {
|
|
return callback(tx);
|
|
}
|
|
// Handle array of promises (batch transaction)
|
|
return Promise.all(callback);
|
|
}),
|
|
operation: {
|
|
create: vi.fn(),
|
|
createMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
aggregate: vi.fn().mockResolvedValue({ _min: { serverSeq: 1 } }),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
userSyncState: {
|
|
findUnique: vi.fn(),
|
|
upsert: vi.fn(),
|
|
update: vi.fn(),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
syncDevice: {
|
|
upsert: vi.fn(),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
$queryRaw: vi.fn().mockResolvedValue([{ total: BigInt(0) }]),
|
|
$executeRaw: vi.fn().mockResolvedValue(0),
|
|
};
|
|
|
|
return {
|
|
prisma: prismaMock,
|
|
// Legacy SQLite-style exports for backwards compatibility
|
|
initDb: (dataPath: string, inMemory: boolean = false) => {
|
|
testData = {
|
|
users: new Map(),
|
|
operations: new Map(),
|
|
syncDevices: new Map(),
|
|
userSyncStates: new Map(),
|
|
};
|
|
serverSeqCounter = 0;
|
|
mockDb = createMockDb();
|
|
},
|
|
getDb: () => {
|
|
if (!mockDb) {
|
|
mockDb = createMockDb();
|
|
}
|
|
return mockDb;
|
|
},
|
|
};
|
|
});
|
|
|
|
// Mock auth module
|
|
vi.mock('../src/auth', () => ({
|
|
verifyToken: vi
|
|
.fn()
|
|
.mockResolvedValue({ valid: true, userId: 1, email: 'test@test.com' }),
|
|
VERIFICATION_TOKEN_EXPIRY_MS: 24 * 60 * 60 * 1000,
|
|
MAX_VERIFICATION_RESEND_COUNT: 20,
|
|
}));
|
|
|
|
// Reset test data before each test
|
|
beforeEach(() => {
|
|
testData = {
|
|
users: new Map(),
|
|
operations: new Map(),
|
|
syncDevices: new Map(),
|
|
userSyncStates: new Map(),
|
|
};
|
|
serverSeqCounter = 0;
|
|
vi.clearAllMocks();
|
|
});
|