Introduce `deleteOwnedDocument` utility to encapsulate all logic for removing a user's ownership of a document, including TTS segment cache cleanup, preview artifact removal, and S3 blob deletion for last-owner cases. Add `withDocumentMutationLock` to serialize concurrent mutations on the same document, using advisory locks in Postgres and a local queue fallback. Refactor all API routes and user data flows to use these utilities, ensuring transactional safety and preventing race conditions during document deletion or transfer. Update tests for new flows and add coverage for document cleanup sequencing and locking behavior.
194 lines
6.2 KiB
TypeScript
194 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) })),
|
|
forUpdate: vi.fn(),
|
|
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,
|
|
for: vi.fn((mode: string) => {
|
|
mocks.forUpdate(mode);
|
|
return Promise.resolve(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: vi.fn(async () => undefined),
|
|
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.forUpdate.mockReset();
|
|
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('locks all document owners before the Postgres 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);
|
|
|
|
expect(mocks.forUpdate).toHaveBeenCalledWith('update');
|
|
});
|
|
});
|