Add tests for user data cleanup with shared document ownership and for TTS segment variant export scenarios, including cases with duplicate audio keys and storage disabled. Refactor cleanup logic to use document row types and transactional ownership removal with last-owner checks. Update TTS segment cache clearing to report deleted segment count by unique entry. Adjust export logic to always include all TTS segment variants and conditionally export file buckets based on storage availability. Update migration to use NOT VALID/VALIDATE for new cascading constraints.
193 lines
6.1 KiB
TypeScript
193 lines
6.1 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,
|
|
})),
|
|
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');
|
|
});
|
|
});
|