Replace mutation-lock with document-lock to centralize document mutation serialization and database transaction management. Introduce runInDbTransaction utility to abstract SQLite/Postgres transaction differences. Update document deletion, user data cleanup, and rate limiter logic to delegate transaction handling and locking to shared helpers. Remove dialect-specific branching and inline transaction logic for improved maintainability and testability. BREAKING CHANGE: withDocumentMutationLock is removed in favor of withDocumentLock and runInDbTransaction
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
selectResults: [] as unknown[][],
|
|
documentDeleteResults: [] as unknown[][],
|
|
deleteWhere: vi.fn(async () => undefined),
|
|
insertValues: vi.fn(() => ({ onConflictDoNothing: vi.fn(async () => undefined) })),
|
|
execute: vi.fn(async () => undefined),
|
|
deleteDocumentBlob: vi.fn(async () => undefined),
|
|
deleteDocumentPrefix: vi.fn(async () => 0),
|
|
deleteDocumentPreviewArtifacts: vi.fn(async () => 0),
|
|
deleteDocumentPreviewRows: vi.fn(async () => undefined),
|
|
deleteAudiobookPrefix: vi.fn(async () => 0),
|
|
deleteTtsSegmentPrefix: vi.fn(async () => 0),
|
|
}));
|
|
|
|
function resultBuilder(result: unknown[]) {
|
|
const limitedResult = {
|
|
all: () => result,
|
|
then: (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) =>
|
|
Promise.resolve(result).then(resolve, reject),
|
|
};
|
|
return {
|
|
all: () => result,
|
|
limit: () => limitedResult,
|
|
then: (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) =>
|
|
Promise.resolve(result).then(resolve, reject),
|
|
};
|
|
}
|
|
|
|
vi.mock('@/db', () => {
|
|
const database = {
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => resultBuilder(mocks.selectResults.shift() ?? [])),
|
|
})),
|
|
})),
|
|
delete: vi.fn(() => ({
|
|
where: vi.fn(() => {
|
|
const promise = mocks.deleteWhere();
|
|
return {
|
|
then: promise.then.bind(promise),
|
|
catch: promise.catch.bind(promise),
|
|
returning: () => resultBuilder(mocks.documentDeleteResults.shift() ?? []),
|
|
};
|
|
}),
|
|
})),
|
|
insert: vi.fn(() => ({
|
|
values: mocks.insertValues,
|
|
})),
|
|
execute: mocks.execute,
|
|
transaction: vi.fn((callback: (tx: unknown) => unknown) => callback(database)),
|
|
};
|
|
return { db: database };
|
|
});
|
|
|
|
vi.mock('@/lib/server/storage/s3', () => ({
|
|
isS3Configured: () => true,
|
|
getS3Config: () => ({ prefix: 'openreader-test' }),
|
|
}));
|
|
|
|
vi.mock('@/lib/server/documents/blobstore', () => ({
|
|
deleteDocumentBlob: mocks.deleteDocumentBlob,
|
|
deleteDocumentPrefix: mocks.deleteDocumentPrefix,
|
|
tempDocumentUploadPrefix: () => 'temp/user/',
|
|
}));
|
|
|
|
vi.mock('@/lib/server/documents/previews-blobstore', () => ({
|
|
deleteDocumentPreviewArtifacts: mocks.deleteDocumentPreviewArtifacts,
|
|
}));
|
|
|
|
vi.mock('@/lib/server/documents/previews', () => ({
|
|
deleteDocumentPreviewRows: mocks.deleteDocumentPreviewRows,
|
|
}));
|
|
|
|
vi.mock('@/lib/server/audiobooks/blobstore', () => ({
|
|
audiobookPrefix: () => 'audiobooks/user/',
|
|
deleteAudiobookPrefix: mocks.deleteAudiobookPrefix,
|
|
}));
|
|
|
|
vi.mock('@/lib/server/tts/segments-blobstore', () => ({
|
|
deleteTtsSegmentPrefix: mocks.deleteTtsSegmentPrefix,
|
|
}));
|
|
|
|
vi.mock('@/lib/server/logger', () => ({
|
|
hashForLog: () => 'hash',
|
|
serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('@/lib/server/errors/logging', () => ({
|
|
logDegraded: vi.fn(),
|
|
}));
|
|
|
|
import { deleteUserStorageData } from '../../src/lib/server/user/data-cleanup';
|
|
|
|
describe('user data cleanup', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mocks.selectResults = [];
|
|
mocks.documentDeleteResults = [];
|
|
for (const mock of Object.values(mocks)) {
|
|
if (typeof mock === 'function' && 'mockReset' in mock) {
|
|
mock.mockReset();
|
|
}
|
|
}
|
|
mocks.deleteWhere.mockResolvedValue(undefined);
|
|
mocks.insertValues.mockReturnValue({ onConflictDoNothing: vi.fn(async () => undefined) });
|
|
mocks.execute.mockResolvedValue(undefined);
|
|
mocks.deleteDocumentBlob.mockResolvedValue(undefined);
|
|
mocks.deleteDocumentPrefix.mockResolvedValue(0);
|
|
mocks.deleteDocumentPreviewArtifacts.mockResolvedValue(0);
|
|
mocks.deleteDocumentPreviewRows.mockResolvedValue(undefined);
|
|
mocks.deleteAudiobookPrefix.mockResolvedValue(0);
|
|
mocks.deleteTtsSegmentPrefix.mockResolvedValue(0);
|
|
});
|
|
|
|
test('keeps shared document blobs and previews', async () => {
|
|
mocks.selectResults = [
|
|
[{ id: 'shared-doc' }],
|
|
[{ id: 'shared-doc' }],
|
|
[],
|
|
];
|
|
mocks.documentDeleteResults = [[{ id: 'shared-doc' }]];
|
|
|
|
await deleteUserStorageData('user-1', null);
|
|
|
|
expect(mocks.deleteDocumentBlob).not.toHaveBeenCalled();
|
|
expect(mocks.deleteDocumentPreviewArtifacts).not.toHaveBeenCalled();
|
|
expect(mocks.deleteDocumentPreviewRows).not.toHaveBeenCalled();
|
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
test('blocks database cleanup when storage cleanup fails', async () => {
|
|
mocks.selectResults = [[], []];
|
|
mocks.deleteDocumentPrefix.mockRejectedValueOnce(new Error('storage unavailable'));
|
|
|
|
await expect(deleteUserStorageData('user-1', null)).rejects.toThrow(
|
|
'User storage cleanup failed',
|
|
);
|
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('restores document ownership when document storage cleanup fails', async () => {
|
|
mocks.selectResults = [
|
|
[{ id: 'doc-1' }],
|
|
[],
|
|
[],
|
|
];
|
|
mocks.documentDeleteResults = [[{ id: 'doc-1' }]];
|
|
mocks.deleteDocumentBlob.mockRejectedValueOnce(new Error('storage unavailable'));
|
|
|
|
await expect(deleteUserStorageData('user-1', null)).rejects.toThrow(
|
|
'User storage cleanup failed',
|
|
);
|
|
|
|
expect(mocks.insertValues).toHaveBeenCalledWith([{ id: 'doc-1' }]);
|
|
});
|
|
|
|
test('does not delete global preview rows during namespaced cleanup', async () => {
|
|
mocks.selectResults = [
|
|
[{ id: 'doc-1' }],
|
|
[],
|
|
[],
|
|
];
|
|
mocks.documentDeleteResults = [[{ id: 'doc-1' }]];
|
|
|
|
await deleteUserStorageData('user-1', 'test-ns');
|
|
|
|
expect(mocks.deleteDocumentPreviewRows).not.toHaveBeenCalled();
|
|
expect(mocks.insertValues).toHaveBeenCalledWith([{ id: 'doc-1' }]);
|
|
});
|
|
|
|
test('acquires a Postgres advisory lock before the ownership decision', async () => {
|
|
vi.stubEnv('POSTGRES_URL', 'postgres://test');
|
|
mocks.selectResults = [
|
|
[{ id: 'doc-1' }],
|
|
[{ id: 'doc-1' }],
|
|
[],
|
|
[],
|
|
];
|
|
mocks.documentDeleteResults = [[{ id: 'doc-1' }]];
|
|
|
|
await deleteUserStorageData('user-1', null);
|
|
|
|
// The mutation lock serializes the read-modify-write via a transaction-scoped
|
|
// advisory lock instead of SELECT ... FOR UPDATE.
|
|
expect(mocks.execute).toHaveBeenCalled();
|
|
});
|
|
});
|