/** * Tests for deletion modules — confirmation_modal.js and delete_manager.js * * Focuses on actual workflow behavior: API calls, error handling, * success/failure callbacks, preview-driven confirmation messages, * and the formatBytes utility. Skips trivial "API shape" assertions. */ // delete_manager.js's formatBytes delegates to the shared window.formatBytes; // load it first so DeleteManager.formatBytes works under test. import '@js/utils/format-bytes.js'; import '@js/deletion/confirmation_modal.js'; describe('DeleteConfirmation tooltip lookup', () => { it('returns the configured tooltip for a known action', () => { // We test that lookup works without asserting exact tooltip text const tooltip = window.DeleteConfirmation.getTooltip('deleteDocument'); expect(tooltip).toBeTypeOf('string'); expect(tooltip.length).toBeGreaterThan(0); }); it('returns a generic fallback for unknown actions', () => { expect(window.DeleteConfirmation.getTooltip('unknownAction')).toBe('Delete this item'); }); it('marks all destructive actions as dangerous', () => { const actions = window.DeleteConfirmation.ACTIONS; // Behavioral invariant: deletion actions whose name starts with "delete" // and which permanently destroy data must be flagged dangerous. expect(actions.deleteDocument.dangerous).toBe(true); expect(actions.deleteCollection.dangerous).toBe(true); expect(actions.bulkDeleteDocuments.dangerous).toBe(true); }); it('marks recoverable actions as not dangerous', () => { const actions = window.DeleteConfirmation.ACTIONS; // Removing a PDF blob keeps the text content searchable — recoverable. expect(actions.deleteBlob.dangerous).toBe(false); expect(actions.removeFromCollection.dangerous).toBe(false); }); }); // Now load delete_manager and stub its dependencies before importing let DM; beforeAll(async () => { window.api = { fetchWithErrorHandling: vi.fn(), getCsrfToken: () => 'test-token', }; window.showToast = vi.fn(); // Auto-confirm: bypass the modal and just run the action immediately. // This lets us test the actual API workflow without the modal UI. window.DeleteConfirmation.confirmAndRun = vi.fn(async (opts, action) => { await action(); }); await import('@js/deletion/delete_manager.js'); DM = window.DeleteManager; }); beforeEach(() => { window.api.fetchWithErrorHandling.mockReset(); window.showToast.mockReset(); window.DeleteConfirmation.confirmAndRun.mockClear(); }); // Flush pending microtasks. delete_manager's skipConfirm path calls // confirmOptions.onConfirm() WITHOUT awaiting it, so test callers need // to wait for the inner async work to settle. This is a small wart in // the source — flushPromises() is the standard workaround. const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)); describe('DeleteManager.formatBytes', () => { it('formats 0 bytes', () => { expect(DM.formatBytes(0)).toBe('0 Bytes'); }); it('formats bytes < 1024', () => { expect(DM.formatBytes(500)).toBe('500 Bytes'); }); it('formats kilobytes with decimal precision', () => { expect(DM.formatBytes(1024)).toBe('1 KB'); expect(DM.formatBytes(1536)).toBe('1.5 KB'); }); it('formats megabytes', () => { expect(DM.formatBytes(1048576)).toBe('1 MB'); }); it('formats gigabytes', () => { expect(DM.formatBytes(1073741824)).toBe('1 GB'); }); it('chooses the right unit for boundary values', () => { expect(DM.formatBytes(1023)).toContain('Bytes'); expect(DM.formatBytes(1024)).toContain('KB'); expect(DM.formatBytes(1024 * 1024 - 1)).toContain('KB'); expect(DM.formatBytes(1024 * 1024)).toContain('MB'); }); }); describe('DeleteManager.deleteDocument', () => { it('calls DELETE on the document endpoint', async () => { // First call: preview lookup (GET). Second call: actual DELETE. window.api.fetchWithErrorHandling .mockResolvedValueOnce({ success: true, title: 'My Doc', has_blob: false, has_text: false }) .mockResolvedValueOnce({ success: true }); await DM.deleteDocument('doc-123'); // Verify a DELETE was made to the right endpoint const deleteCall = window.api.fetchWithErrorHandling.mock.calls.find( ([_url, opts]) => opts && opts.method === 'DELETE' ); expect(deleteCall).toBeDefined(); expect(deleteCall[0]).toContain('doc-123'); }); it('skips the preview when skipConfirm is true', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true }); await DM.deleteDocument('doc-456', { skipConfirm: true }); await flushPromises(); // Only one call (the DELETE), no preview GET expect(window.api.fetchWithErrorHandling).toHaveBeenCalledTimes(1); expect(window.api.fetchWithErrorHandling.mock.calls[0][1].method).toBe('DELETE'); }); it('calls onSuccess callback with API result on success', async () => { const onSuccess = vi.fn(); const apiResult = { success: true, deleted_id: 'doc-789' }; window.api.fetchWithErrorHandling.mockResolvedValueOnce(apiResult); await DM.deleteDocument('doc-789', { skipConfirm: true, onSuccess }); await flushPromises(); expect(onSuccess).toHaveBeenCalledWith(apiResult); }); it('calls onError callback when API returns success: false', async () => { const onError = vi.fn(); window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: false, error: 'Document is locked', }); await DM.deleteDocument('doc-locked', { skipConfirm: true, onError }); await flushPromises(); expect(onError).toHaveBeenCalled(); const err = onError.mock.calls[0][0]; expect(err.message).toContain('Document is locked'); }); it('calls onError when fetch throws', async () => { const onError = vi.fn(); window.api.fetchWithErrorHandling.mockRejectedValueOnce(new Error('Network down')); await DM.deleteDocument('doc-net', { skipConfirm: true, onError }); await flushPromises(); expect(onError).toHaveBeenCalled(); }); it('does not call onSuccess when API call fails', async () => { const onSuccess = vi.fn(); window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: false, error: 'fail' }); await DM.deleteDocument('doc-x', { skipConfirm: true, onSuccess, onError: () => {} }); expect(onSuccess).not.toHaveBeenCalled(); }); }); describe('DeleteManager.deleteDocumentBlob', () => { it('calls DELETE on the document blob endpoint', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, bytes_freed: 2048 }); await DM.deleteDocumentBlob('doc-1', { skipConfirm: true }); await flushPromises(); const call = window.api.fetchWithErrorHandling.mock.calls[0]; expect(call[0]).toContain('doc-1'); expect(call[0]).toContain('blob'); expect(call[1].method).toBe('DELETE'); }); it('passes bytes_freed to onSuccess', async () => { const onSuccess = vi.fn(); window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, bytes_freed: 5000, }); await DM.deleteDocumentBlob('doc-1', { skipConfirm: true, onSuccess }); await flushPromises(); expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ bytes_freed: 5000 })); }); }); describe('DeleteManager.removeFromCollection', () => { it('calls DELETE on the collection-document endpoint with both IDs', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, document_deleted: false, }); await DM.removeFromCollection('doc-1', 'coll-2', { skipConfirm: true }); await flushPromises(); const call = window.api.fetchWithErrorHandling.mock.calls[0]; expect(call[0]).toContain('doc-1'); expect(call[0]).toContain('coll-2'); expect(call[1].method).toBe('DELETE'); }); it('handles document_deleted: true (last collection removal)', async () => { const onSuccess = vi.fn(); window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, document_deleted: true, }); await DM.removeFromCollection('doc-1', 'coll-2', { skipConfirm: true, onSuccess }); await flushPromises(); expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ document_deleted: true })); }); }); describe('DeleteManager.deleteCollection', () => { it('calls DELETE on the collection endpoint', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, documents_unlinked: 5, chunks_deleted: 100, }); await DM.deleteCollection('coll-99', { skipConfirm: true }); await flushPromises(); const call = window.api.fetchWithErrorHandling.mock.calls[0]; expect(call[0]).toContain('coll-99'); expect(call[1].method).toBe('DELETE'); }); it('reports failure via onError', async () => { const onError = vi.fn(); window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: false, error: 'Collection in use', }); await DM.deleteCollection('coll-used', { skipConfirm: true, onError }); await flushPromises(); expect(onError).toHaveBeenCalled(); }); }); describe('DeleteManager.deleteCollectionIndex', () => { it('calls DELETE on the index endpoint', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, chunks_deleted: 42, }); await DM.deleteCollectionIndex('coll-1', { skipConfirm: true }); await flushPromises(); const call = window.api.fetchWithErrorHandling.mock.calls[0]; expect(call[0]).toContain('coll-1'); expect(call[0]).toContain('index'); expect(call[1].method).toBe('DELETE'); }); }); describe('DeleteManager.bulkDeleteDocuments', () => { it('sends document IDs in the request body', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, deleted: 3, total: 3, total_bytes_freed: 1024, }); await DM.bulkDeleteDocuments(['a', 'b', 'c'], { skipConfirm: true }); await flushPromises(); const call = window.api.fetchWithErrorHandling.mock.calls[0]; expect(call[1].method).toBe('DELETE'); const body = JSON.parse(call[1].body); expect(body.document_ids).toEqual(['a', 'b', 'c']); }); it('reports partial-success counts via onSuccess', async () => { const onSuccess = vi.fn(); window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, deleted: 2, total: 3, total_bytes_freed: 2048, }); await DM.bulkDeleteDocuments(['a', 'b', 'c'], { skipConfirm: true, onSuccess }); await flushPromises(); expect(onSuccess).toHaveBeenCalledWith( expect.objectContaining({ deleted: 2, total: 3 }) ); }); }); describe('DeleteManager.bulkDeleteBlobs', () => { it('sends document IDs in the request body', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, deleted: 2, total_bytes_freed: 1024, }); await DM.bulkDeleteBlobs(['x', 'y'], { skipConfirm: true }); await flushPromises(); const call = window.api.fetchWithErrorHandling.mock.calls[0]; const body = JSON.parse(call[1].body); expect(body.document_ids).toEqual(['x', 'y']); }); }); describe('DeleteManager.bulkRemoveFromCollection', () => { it('sends collection ID in URL and document IDs in body', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true, unlinked: 3, deleted: 1, }); await DM.bulkRemoveFromCollection(['a', 'b', 'c'], 'coll-7', { skipConfirm: true }); await flushPromises(); const call = window.api.fetchWithErrorHandling.mock.calls[0]; expect(call[0]).toContain('coll-7'); expect(call[1].method).toBe('DELETE'); const body = JSON.parse(call[1].body); expect(body.document_ids).toEqual(['a', 'b', 'c']); }); }); describe('DeleteManager confirmation flow', () => { it('skipConfirm: true bypasses DeleteConfirmation.confirmAndRun', async () => { window.api.fetchWithErrorHandling.mockResolvedValueOnce({ success: true }); await DM.deleteDocument('doc-1', { skipConfirm: true }); await flushPromises(); expect(window.DeleteConfirmation.confirmAndRun).not.toHaveBeenCalled(); }); it('skipConfirm: false routes through confirmation', async () => { // The first call is the preview lookup window.api.fetchWithErrorHandling .mockResolvedValueOnce({ success: true, title: 'Doc' }) .mockResolvedValueOnce({ success: true }); await DM.deleteDocument('doc-2'); expect(window.DeleteConfirmation.confirmAndRun).toHaveBeenCalled(); }); it('preview failure does not block the deletion (warning only)', async () => { // Preview throws, but the deletion should still proceed via confirmAndRun window.api.fetchWithErrorHandling .mockRejectedValueOnce(new Error('preview failed')) .mockResolvedValueOnce({ success: true }); await DM.deleteDocument('doc-3'); // confirmAndRun was still called → user still gets the dialog expect(window.DeleteConfirmation.confirmAndRun).toHaveBeenCalled(); }); });