From 4b773f6dd7dda363660081a2f6dacded150b90a1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 11 Mar 2025 12:32:20 +0100 Subject: [PATCH] fix(files_trashbin): return correct type in empty trash action 1. correctly return null instead of void in empty trash action 2. use constant instead of magic value for action ID 3. add unit tests for empty trash action 4. add unit tests for trashbin api Signed-off-by: Ferdinand Thiessen --- apps/files_trashbin/src/files-init.ts | 15 +- .../emptyTrashAction.spec.ts | 178 ++++++++++++++++++ .../emptyTrashAction.ts | 32 +--- .../src/files_views/trashbinView.ts | 4 +- apps/files_trashbin/src/services/api.spec.ts | 43 +++++ apps/files_trashbin/src/services/api.ts | 28 +++ 6 files changed, 263 insertions(+), 37 deletions(-) create mode 100644 apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts rename apps/files_trashbin/src/{fileListActions => files_listActions}/emptyTrashAction.ts (72%) create mode 100644 apps/files_trashbin/src/services/api.spec.ts create mode 100644 apps/files_trashbin/src/services/api.ts diff --git a/apps/files_trashbin/src/files-init.ts b/apps/files_trashbin/src/files-init.ts index b4526c97143..9af20693da6 100644 --- a/apps/files_trashbin/src/files-init.ts +++ b/apps/files_trashbin/src/files-init.ts @@ -3,21 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getNavigation, registerFileListAction } from '@nextcloud/files' +import { emptyTrashAction } from './files_actions/emptyTrashAction.ts' import { trashbinView } from './files_views/trashbinView.ts' + import './trashbin.scss' -import { translate as t } from '@nextcloud/l10n' -import { View, getNavigation, registerFileListAction } from '@nextcloud/files' -import DeleteSvg from '@mdi/svg/svg/delete.svg?raw' - -import { getContents } from './services/trashbin' -import { columns } from './columns.ts' - -// Register restore action -import './actions/restoreAction' - -import { emptyTrashAction } from './fileListActions/emptyTrashAction.ts' - const Navigation = getNavigation() Navigation.register(trashbinView) diff --git a/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts b/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts new file mode 100644 index 00000000000..ba9697351e8 --- /dev/null +++ b/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts @@ -0,0 +1,178 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Folder } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { emptyTrashAction } from './emptyTrashAction.ts' +import { trashbinView } from '../files_views/trashbinView.ts' +import * as ncDialogs from '@nextcloud/dialogs' +import * as ncEventBus from '@nextcloud/event-bus' +import * as ncInitialState from '@nextcloud/initial-state' +import * as api from '../services/api.ts' + +describe('files_trashbin: file list actions - empty trashbin', () => { + it('has id set', () => { + expect(emptyTrashAction.id).toBe('empty-trash') + }) + + it('has display name set', () => { + expect(emptyTrashAction.displayName(trashbinView)).toBe('Empty deleted files') + }) + + it('has order set', () => { + // expect highest priority! + expect(emptyTrashAction.order).toBe(0) + }) + + it('is enabled on trashbin view', () => { + const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(true) + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith('files_trashbin', 'config') + }) + + it('is not enabled on another view enabled', () => { + vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + const otherView = new Proxy(trashbinView, { + get(target, p) { + if (p === 'id') { + return 'other-view' + } + return target[p] + }, + }) + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(otherView, nodes, root)).toBe(false) + }) + + it('is not enabled when deletion is forbidden', () => { + const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: false })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false) + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith('files_trashbin', 'config') + }) + + it('is not enabled when not in trashbin root', () => { + vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true })) + + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/other-folder', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + expect(emptyTrashAction.enabled).toBeTypeOf('function') + expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false) + }) + + describe('execute', () => { + const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' }) + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }), + ] + + let dialogBuilder = { + setSeverity: vi.fn(), + setText: vi.fn(), + setButtons: vi.fn(), + build: vi.fn(), + } + + beforeEach(() => { + dialogBuilder = { + setSeverity: vi.fn(() => dialogBuilder), + setText: vi.fn(() => dialogBuilder), + setButtons: vi.fn(() => dialogBuilder), + build: vi.fn(() => dialogBuilder), + } + + vi.spyOn(ncDialogs, 'getDialogBuilder') + // @ts-expect-error This is a mock + .mockImplementationOnce(() => dialogBuilder) + }) + + it('can cancel the deletion by closing the dialog', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash') + const dialogSpy = vi.spyOn(ncDialogs, 'showInfo') + + dialogBuilder.build.mockImplementationOnce(() => ({ show: async () => false })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).not.toBeCalled() + expect(dialogSpy).toBeCalledWith('Deletion cancelled') + }) + + it('can cancel the deletion', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash') + const dialogSpy = vi.spyOn(ncDialogs, 'showInfo') + + dialogBuilder.build.mockImplementationOnce(() => ({ + show: async () => { + const buttons = dialogBuilder.setButtons.mock.calls[0][0] + const cancel = buttons.find(({ label }) => label === 'Cancel') + await cancel.callback() + }, + })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).not.toBeCalled() + expect(dialogSpy).toBeCalledWith('Deletion cancelled') + }) + + it('will trigger the API request if confirmed', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash').mockImplementationOnce(async () => true) + const dialogSpy = vi.spyOn(ncDialogs, 'showInfo') + const eventBusSpy = vi.spyOn(ncEventBus, 'emit') + + dialogBuilder.build.mockImplementationOnce(() => ({ + show: async () => { + const buttons = dialogBuilder.setButtons.mock.calls[0][0] + const cancel = buttons.find(({ label }) => label === 'Empty deleted files') + await cancel.callback() + }, + })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).toBeCalled() + expect(dialogSpy).not.toBeCalled() + expect(eventBusSpy).toBeCalledWith('files:node:deleted', nodes[0]) + }) + + it('will not emit files deleted event if API request failed', async () => { + const apiSpy = vi.spyOn(api, 'emptyTrash').mockImplementationOnce(async () => false) + const dialogSpy = vi.spyOn(ncDialogs, 'showInfo') + const eventBusSpy = vi.spyOn(ncEventBus, 'emit') + + dialogBuilder.build.mockImplementationOnce(() => ({ + show: async () => { + const buttons = dialogBuilder.setButtons.mock.calls[0][0] + const cancel = buttons.find(({ label }) => label === 'Empty deleted files') + await cancel.callback() + }, + })) + expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null) + expect(apiSpy).toBeCalled() + expect(dialogSpy).not.toBeCalled() + expect(eventBusSpy).not.toBeCalled() + }) + }) +}) diff --git a/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts b/apps/files_trashbin/src/files_listActions/emptyTrashAction.ts similarity index 72% rename from apps/files_trashbin/src/fileListActions/emptyTrashAction.ts rename to apps/files_trashbin/src/files_listActions/emptyTrashAction.ts index f9cc3b301db..67d6c9c373b 100644 --- a/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts +++ b/apps/files_trashbin/src/files_listActions/emptyTrashAction.ts @@ -4,39 +4,22 @@ */ import type { Node, View, Folder } from '@nextcloud/files' -import axios from '@nextcloud/axios' +import { emit } from '@nextcloud/event-bus' import { FileListAction } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { DialogSeverity, getDialogBuilder, - showError, showInfo, - showSuccess, } from '@nextcloud/dialogs' - -import { logger } from '../logger.ts' -import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' -import { emit } from '@nextcloud/event-bus' -import { loadState } from '@nextcloud/initial-state' +import { emptyTrash } from '../services/api.ts' +import { TRASHBIN_VIEW_ID } from '../files_views/trashbinView.ts' export type FilesTrashbinConfigState = { allow_delete: boolean; } -const emptyTrash = async (): Promise => { - try { - await axios.delete(generateRemoteUrl('dav') + `/trashbin/${getCurrentUser()?.uid}/trash`) - showSuccess(t('files_trashbin', 'All files have been permanently deleted')) - return true - } catch (error) { - showError(t('files_trashbin', 'Failed to empty deleted files')) - logger.error('Failed to empty deleted files', { error }) - return false - } -} - export const emptyTrashAction = new FileListAction({ id: 'empty-trash', @@ -44,7 +27,7 @@ export const emptyTrashAction = new FileListAction({ order: 0, enabled(view: View, nodes: Node[], folder: Folder) { - if (view.id !== 'trashbin') { + if (view.id !== TRASHBIN_VIEW_ID) { return false } @@ -56,7 +39,7 @@ export const emptyTrashAction = new FileListAction({ return nodes.length > 0 && folder.path === '/' }, - async exec(view: View, nodes: Node[]): Promise { + async exec(view: View, nodes: Node[]): Promise { const askConfirmation = new Promise((resolve) => { const dialog = getDialogBuilder(t('files_trashbin', 'Confirm permanent deletion')) .setSeverity(DialogSeverity.Warning) @@ -85,9 +68,10 @@ export const emptyTrashAction = new FileListAction({ if (await emptyTrash()) { nodes.forEach((node) => emit('files:node:deleted', node)) } - return + return null } showInfo(t('files_trashbin', 'Deletion cancelled')) + return null }, }) diff --git a/apps/files_trashbin/src/files_views/trashbinView.ts b/apps/files_trashbin/src/files_views/trashbinView.ts index cb2a83368ea..5b547071cc7 100644 --- a/apps/files_trashbin/src/files_views/trashbinView.ts +++ b/apps/files_trashbin/src/files_views/trashbinView.ts @@ -9,8 +9,10 @@ import { getContents } from '../services/trashbin.ts' import svgDelete from '@mdi/svg/svg/delete.svg?raw' +export const TRASHBIN_VIEW_ID = 'trashbin' + export const trashbinView = new View({ - id: 'trashbin', + id: TRASHBIN_VIEW_ID, name: t('files_trashbin', 'Deleted files'), caption: t('files_trashbin', 'List of files that have been deleted.'), diff --git a/apps/files_trashbin/src/services/api.spec.ts b/apps/files_trashbin/src/services/api.spec.ts new file mode 100644 index 00000000000..b50a53b8e07 --- /dev/null +++ b/apps/files_trashbin/src/services/api.spec.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { emptyTrash } from './api.ts' +import * as ncAuth from '@nextcloud/auth' +import * as ncDialogs from '@nextcloud/dialogs' +import * as logger from '../logger.ts' + +const axiosMock = vi.hoisted(() => ({ + delete: vi.fn(), +})) +vi.mock('@nextcloud/axios', () => ({ default: axiosMock })) + +describe('files_trashbin: API - emptyTrash', () => { + beforeEach(() => { + vi.spyOn(ncAuth, 'getCurrentUser').mockImplementationOnce(() => ({ + uid: 'test', + displayName: 'Test', + isAdmin: false, + })) + }) + + it('shows success', async () => { + const dialogSpy = vi.spyOn(ncDialogs, 'showSuccess') + expect(await emptyTrash()).toBe(true) + expect(axiosMock.delete).toBeCalled() + expect(dialogSpy).toBeCalledWith('All files have been permanently deleted') + }) + + it('shows failure', async () => { + axiosMock.delete.mockImplementationOnce(() => { throw new Error() }) + const dialogSpy = vi.spyOn(ncDialogs, 'showError') + const loggerSpy = vi.spyOn(logger.logger, 'error').mockImplementationOnce(() => {}) + + expect(await emptyTrash()).toBe(false) + expect(axiosMock.delete).toBeCalled() + expect(dialogSpy).toBeCalledWith('Failed to empty deleted files') + expect(loggerSpy).toBeCalled() + }) +}) diff --git a/apps/files_trashbin/src/services/api.ts b/apps/files_trashbin/src/services/api.ts new file mode 100644 index 00000000000..b1f2e98b2d9 --- /dev/null +++ b/apps/files_trashbin/src/services/api.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { defaultRemoteURL } from '@nextcloud/files/dav' +import { t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' + +import { logger } from '../logger.ts' + +/** + * Send API request to empty the trashbin. + * Returns true if request succeeded - otherwise false is returned. + */ +export async function emptyTrash(): Promise { + try { + await axios.delete(`${defaultRemoteURL}/trashbin/${getCurrentUser()!.uid}/trash`) + showSuccess(t('files_trashbin', 'All files have been permanently deleted')) + return true + } catch (error) { + showError(t('files_trashbin', 'Failed to empty deleted files')) + logger.error('Failed to empty deleted files', { error }) + return false + } +}