openreader/tests/unit/user-data-cleanup.vitest.spec.ts
Richard R 849f4a43e8 test(user): expand cleanup and export coverage for shared docs and TTS segment files
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.
2026-06-06 17:38:30 -06:00

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');
});
});