diff --git a/src/App.vue b/src/App.vue index 5ba4789edc..3fda7423c5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,7 +23,7 @@ diff --git a/src/tests/views/Documents/IdDocsValidation.spec.ts b/src/tests/views/Documents/IdDocsValidation.spec.ts new file mode 100644 index 0000000000..baf597fd52 --- /dev/null +++ b/src/tests/views/Documents/IdDocsValidation.spec.ts @@ -0,0 +1,292 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import IdDocsValidation from '../../../views/Documents/IdDocsValidation.vue' +import { FILE_STATUS } from '../../../constants.js' + +const axiosGetMock = vi.fn() +const axiosDeleteMock = vi.fn() +const showErrorMock = vi.fn() +const openDocumentMock = vi.fn() +const routerPushMock = vi.fn() +const userConfigUpdateMock = vi.fn() + +const userConfigStore = { + id_docs_filters: { + owner: '', + status: null, + }, + id_docs_sort: { + sortBy: 'owner', + sortOrder: 'DESC', + }, + update: vi.fn((...args: unknown[]) => userConfigUpdateMock(...args)), +} + +vi.mock('@nextcloud/l10n', () => ({ + t: vi.fn((_app: string, text: string) => text), +})) + +vi.mock('@nextcloud/axios', () => ({ + default: { + get: vi.fn((...args: unknown[]) => axiosGetMock(...args)), + delete: vi.fn((...args: unknown[]) => axiosDeleteMock(...args)), + }, +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showError: vi.fn((...args: unknown[]) => showErrorMock(...args)), +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path: string, params?: Record) => { + let resolvedPath = path + for (const [key, value] of Object.entries(params || {})) { + resolvedPath = resolvedPath.replace(`{${key}}`, String(value)) + } + return `/ocs/v2.php${resolvedPath}` + }), +})) + +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ + push: routerPushMock, + })), +})) + +vi.mock('../../../store/userconfig.js', () => ({ + useUserConfigStore: vi.fn(() => userConfigStore), +})) + +vi.mock('../../../utils/viewer.js', () => ({ + openDocument: vi.fn((...args: unknown[]) => openDocumentMock(...args)), +})) + +vi.mock('@nextcloud/vue/components/NcActions', () => ({ + default: { name: 'NcActions', template: '
' }, +})) + +vi.mock('@nextcloud/vue/components/NcActionButton', () => ({ + default: { + name: 'NcActionButton', + emits: ['click', 'update:modelValue'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcActionInput', () => ({ + default: { + name: 'NcActionInput', + props: ['modelValue', 'label'], + emits: ['update:modelValue'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcActionSeparator', () => ({ + default: { name: 'NcActionSeparator', template: '
' }, +})) + +vi.mock('@nextcloud/vue/components/NcAvatar', () => ({ + default: { name: 'NcAvatar', template: '
' }, +})) + +vi.mock('@nextcloud/vue/components/NcEmptyContent', () => ({ + default: { name: 'NcEmptyContent', template: '
' }, +})) + +vi.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({ + default: { name: 'NcLoadingIcon', template: '' }, +})) + +vi.mock('@nextcloud/vue/components/NcIconSvgWrapper', () => ({ + default: { name: 'NcIconSvgWrapper', template: '' }, +})) + +describe('IdDocsValidation.vue', () => { + const signedDoc = { + uuid: 'doc-1', + account: { + userId: 'alice', + displayName: 'Alice', + }, + file_type: { + type: 'passport', + name: 'Passport', + }, + file: { + uuid: 'file-1', + status: FILE_STATUS.SIGNED, + statusText: 'Signed', + name: 'alice-passport.pdf', + file: { + nodeId: 10, + url: '/files/alice-passport.pdf', + }, + signers: [{ uid: 'approver', displayName: 'Approver', sign_date: '2026-03-06T12:00:00Z' }], + }, + } + + const pendingDoc = { + uuid: 'doc-2', + account: { + userId: 'bob', + displayName: 'Bob', + }, + file_type: { + type: 'driver-license', + name: 'Driver License', + }, + file: { + uuid: 'file-2', + status: FILE_STATUS.ABLE_TO_SIGN, + statusText: 'Pending', + name: 'bob-license.pdf', + file: { + nodeId: 11, + url: '/files/bob-license.pdf', + }, + signers: [], + }, + } + + const createWrapper = () => mount(IdDocsValidation) + + beforeEach(() => { + axiosGetMock.mockReset() + axiosDeleteMock.mockReset() + showErrorMock.mockReset() + openDocumentMock.mockReset() + routerPushMock.mockReset() + userConfigUpdateMock.mockReset() + userConfigStore.update.mockClear() + userConfigStore.id_docs_filters = { owner: '', status: null } + userConfigStore.id_docs_sort = { sortBy: 'owner', sortOrder: 'DESC' } + + axiosGetMock.mockResolvedValue({ + data: { + ocs: { + data: { + data: [signedDoc, pendingDoc], + total: 2, + }, + }, + }, + }) + + axiosDeleteMock.mockResolvedValue({ + data: { + ocs: { + data: { + success: true, + }, + }, + }, + }) + }) + + it('loads documents on mount using saved sort', async () => { + const wrapper = createWrapper() + await flushPromises() + + expect(axiosGetMock).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/id-docs/approval/list', { + params: { + page: 1, + length: 50, + sortBy: 'owner', + sortOrder: 'DESC', + }, + }) + expect(wrapper.vm.documentList).toHaveLength(2) + expect(wrapper.vm.hasMore).toBe(false) + }) + + it('filters by owner and status and persists filter changes', async () => { + vi.useFakeTimers() + const wrapper = createWrapper() + await flushPromises() + + wrapper.vm.filters.owner = 'bob' + wrapper.vm.setStatusFilter('pending', true) + vi.runAllTimers() + await flushPromises() + + expect(wrapper.vm.hasActiveFilters).toBe(true) + expect(wrapper.vm.activeFilterCount).toBe(2) + expect(wrapper.vm.filteredDocuments).toEqual([pendingDoc]) + expect(userConfigUpdateMock).toHaveBeenCalledWith('id_docs_filters', { + owner: 'bob', + status: { + value: 'pending', + label: 'Pending', + }, + }) + + vi.useRealTimers() + }) + + it('toggles sort direction and then clears the sort for the same column', async () => { + const wrapper = createWrapper() + await flushPromises() + + await wrapper.vm.sortColumn('owner') + expect(wrapper.vm.sortOrder).toBe('ASC') + + await wrapper.vm.sortColumn('owner') + expect(wrapper.vm.sortBy).toBeNull() + expect(wrapper.vm.sortOrder).toBeNull() + expect(userConfigUpdateMock).toHaveBeenCalledWith('id_docs_sort', { + sortBy: null, + sortOrder: null, + }) + }) + + it('routes to approve and validation pages using document uuid', async () => { + const wrapper = createWrapper() + await flushPromises() + + wrapper.vm.openApprove(pendingDoc) + wrapper.vm.openValidationURL(signedDoc) + + expect(routerPushMock).toHaveBeenNthCalledWith(1, { + name: 'IdDocsApprove', + params: { uuid: 'file-2' }, + query: { idDocApproval: 'true' }, + }) + expect(routerPushMock).toHaveBeenNthCalledWith(2, { + name: 'ValidationFile', + params: { uuid: 'file-1' }, + }) + }) + + it('opens the file in the viewer and reports missing urls', async () => { + const wrapper = createWrapper() + await flushPromises() + + wrapper.vm.openFile(signedDoc) + wrapper.vm.openFile({ file: { file: { nodeId: 12 }, name: 'missing.pdf' } }) + + expect(openDocumentMock).toHaveBeenCalledWith({ + fileUrl: '/files/alice-passport.pdf', + filename: 'alice-passport.pdf', + nodeId: 10, + }) + expect(showErrorMock).toHaveBeenCalledWith('File not found') + }) + + it('deletes a document and reloads the list', async () => { + const wrapper = createWrapper() + await flushPromises() + + await wrapper.vm.deleteDocument(signedDoc) + await flushPromises() + + expect(axiosDeleteMock).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/id-docs/10') + expect(axiosGetMock).toHaveBeenCalledTimes(2) + }) +}) \ No newline at end of file diff --git a/src/tests/views/FilesList/FileEntry.spec.ts b/src/tests/views/FilesList/FileEntry.spec.ts index dc3fd9def9..66178a8708 100644 --- a/src/tests/views/FilesList/FileEntry.spec.ts +++ b/src/tests/views/FilesList/FileEntry.spec.ts @@ -9,6 +9,7 @@ import { setActivePinia, createPinia } from 'pinia' import FileEntry from '../../../views/FilesList/FileEntry/FileEntry.vue' import { useFilesStore } from '../../../store/files.js' import { useActionsMenuStore } from '../../../store/actionsmenu.js' +import { useSidebarStore } from '../../../store/sidebar.js' import type { TranslationFunction } from '../../test-types' type FileEntrySource = { @@ -18,6 +19,9 @@ type FileEntrySource = { statusText: string signers: unknown[] created_at: number + metadata?: { + extension?: string + } } const t: TranslationFunction = (_app, text) => text @@ -91,39 +95,33 @@ vi.mock('../../../views/FilesList/FileEntry/FileEntrySigners.vue', () => ({ }, })) -vi.mock('../../../views/FilesList/FileEntry/FileEntryMixin.js', () => ({ - default: { - computed: { - fileExtension() { - return 'pdf' - }, - mtime(this: { source?: FileEntrySource }): number { - return this.source?.created_at ?? Date.now() - }, - mtimeOpacity() { - return {} - }, +describe('FileEntry.vue - Individual File Entry', () => { + const source: FileEntrySource = { + id: 1, + name: 'test.pdf', + status: 1, + statusText: 'Ready', + signers: [], + created_at: Date.now(), + metadata: { + extension: 'pdf', }, - data() { - const source: FileEntrySource = { - id: 1, - name: 'test.pdf', - status: 1, - statusText: 'Ready', - signers: [], - created_at: Date.now(), - } - - return { + } + + function createWrapper() { + return mount(FileEntry, { + props: { source, loading: false, - openedMenu: false, - } - }, - }, -})) + }, + global: { + mocks: { + t, + }, + }, + }) + } -describe('FileEntry.vue - Individual File Entry', () => { beforeEach(() => { setActivePinia(createPinia()) }) @@ -133,53 +131,25 @@ describe('FileEntry.vue - Individual File Entry', () => { }) it('renders file entry row', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.find('tr.files-list__row').exists()).toBe(true) }) it('initializes renaming state as false', () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.vm.isRenaming).toBe(false) }) it('initializes renaming saving state as false', () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.vm.renamingSaving).toBe(false) }) it('renames file on rename event', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() await wrapper.vm.onRename('newname.pdf') @@ -187,14 +157,7 @@ describe('FileEntry.vue - Individual File Entry', () => { }) it('clears renaming saving flag after rename', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() wrapper.vm.renamingSaving = true @@ -203,29 +166,15 @@ describe('FileEntry.vue - Individual File Entry', () => { }) it('starts rename on file', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() wrapper.vm.onStartRename() - expect(wrapper.vm.$refs.name).toBeDefined() + expect(wrapper.vm.name).toBeDefined() }) it('tracks file renaming state', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() expect(wrapper.vm.isRenaming).toBe(false) @@ -237,14 +186,7 @@ describe('FileEntry.vue - Individual File Entry', () => { it('uses files store for file operations', async () => { const store = useFilesStore() - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() expect(wrapper.vm.filesStore).toBe(store) @@ -252,93 +194,44 @@ describe('FileEntry.vue - Individual File Entry', () => { it('uses actions menu store for menu state', async () => { const store = useActionsMenuStore() - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() expect(wrapper.vm.actionsMenuStore).toBe(store) }) it('renders file entry checkbox', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.findComponent({ name: 'FileEntryCheckbox' }).exists()).toBe(true) }) it('renders file entry name', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.findComponent({ name: 'FileEntryName' }).exists()).toBe(true) }) it('renders file entry actions', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.findComponent({ name: 'FileEntryActions' }).exists()).toBe(true) }) it('renders file entry status', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.findComponent({ name: 'FileEntryStatus' }).exists()).toBe(true) }) it('renders file entry signers', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.findComponent({ name: 'FileEntrySigners' }).exists()).toBe(true) }) it('passes signersCount to FileEntrySigners', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() const signersComponent = wrapper.findComponent({ name: 'FileEntrySigners' }) @@ -347,14 +240,7 @@ describe('FileEntry.vue - Individual File Entry', () => { }) it('passes signers array to FileEntrySigners', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() const signersComponent = wrapper.findComponent({ name: 'FileEntrySigners' }) @@ -363,41 +249,20 @@ describe('FileEntry.vue - Individual File Entry', () => { }) it('renders row signers cell with click handler', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() const signersCell = wrapper.find('.files-list__row-signers') expect(signersCell.exists()).toBe(true) }) it('renders file modification time', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() expect(wrapper.findComponent({ name: 'NcDateTime' }).exists()).toBe(true) }) it('passes source to child components', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() expect(wrapper.vm.filesStore).toBeDefined() @@ -405,32 +270,34 @@ describe('FileEntry.vue - Individual File Entry', () => { it('shows success message after rename', async () => { const { showSuccess } = await import('@nextcloud/dialogs') - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() await wrapper.vm.onRename('newname.pdf') expect(showSuccess).toHaveBeenCalled() }) + it('opens details through the sidebar store', async () => { + const sidebarStore = useSidebarStore() + const selectFileSpy = vi.spyOn(useFilesStore(), 'selectFile') + const activeRequestSignatureTabSpy = vi.spyOn(sidebarStore, 'activeRequestSignatureTab') + const wrapper = createWrapper() + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as Event + + wrapper.vm.openDetailsIfAvailable(event) + + expect(selectFileSpy).toHaveBeenCalledWith(1) + expect(activeRequestSignatureTabSpy).toHaveBeenCalled() + }) + it('restores file name on rename failure', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() - vi.spyOn(wrapper.vm.$refs.actions, 'doRename').mockRejectedValueOnce(new Error('Rename failed')) + vi.spyOn(wrapper.vm.actions, 'doRename').mockRejectedValueOnce(new Error('Rename failed')) try { await wrapper.vm.onRename('newname.pdf') } catch (e) {} @@ -438,15 +305,7 @@ describe('FileEntry.vue - Individual File Entry', () => { }) it('handles right click on row', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - onRightClick: vi.fn(), - }, - }, - }) + const wrapper = createWrapper() await wrapper.vm.$nextTick() const row = wrapper.find('.files-list__row') @@ -454,42 +313,21 @@ describe('FileEntry.vue - Individual File Entry', () => { }) it('renders row name cell with click handler', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() const nameCell = wrapper.find('.files-list__row-name') expect(nameCell.exists()).toBe(true) }) it('renders row status cell with click handler', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() const statusCell = wrapper.find('.files-list__row-status') expect(statusCell.exists()).toBe(true) }) it('renders row mtime cell', async () => { - const wrapper = mount(FileEntry, { - props: {}, - global: { - mocks: { - t, - }, - }, - }) + const wrapper = createWrapper() const mtimeCell = wrapper.find('.files-list__row-mtime') expect(mtimeCell.exists()).toBe(true) diff --git a/src/tests/views/FilesList/FileEntryGrid.spec.ts b/src/tests/views/FilesList/FileEntryGrid.spec.ts index 6e76d51f30..eb2c6dbb3e 100644 --- a/src/tests/views/FilesList/FileEntryGrid.spec.ts +++ b/src/tests/views/FilesList/FileEntryGrid.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' import FileEntryGrid from '../../../views/FilesList/FileEntry/FileEntryGrid.vue' @@ -14,6 +15,11 @@ const actionsMenuStoreMock = { const filesStoreMock = { loading: true, + selectFile: vi.fn(), +} + +const sidebarStoreMock = { + activeRequestSignatureTab: vi.fn(), } vi.mock('@nextcloud/vue/components/NcDateTime', () => ({ @@ -72,42 +78,6 @@ vi.mock('../../../views/FilesList/FileEntry/FileEntryStatus.vue', () => ({ }, })) -vi.mock('../../../views/FilesList/FileEntry/FileEntryMixin.js', () => ({ - default: { - props: { - source: { - type: Object, - required: true, - }, - loading: { - type: Boolean, - required: true, - }, - }, - computed: { - fileExtension() { - return '.pdf' - }, - mtime() { - return new Date('2026-03-06T10:00:00Z') - }, - mtimeOpacity() { - return { color: 'red' } - }, - openedMenu: { - get() { - return false - }, - set() {}, - }, - }, - methods: { - onRightClick: vi.fn(), - openDetailsIfAvailable: vi.fn(), - }, - }, -})) - vi.mock('../../../store/actionsmenu.js', () => ({ useActionsMenuStore: vi.fn(() => actionsMenuStoreMock), })) @@ -116,10 +86,17 @@ vi.mock('../../../store/files.js', () => ({ useFilesStore: vi.fn(() => filesStoreMock), })) +vi.mock('../../../store/sidebar.js', () => ({ + useSidebarStore: vi.fn(() => sidebarStoreMock), +})) + describe('FileEntryGrid.vue', () => { beforeEach(() => { + setActivePinia(createPinia()) actionsMenuStoreMock.opened = null filesStoreMock.loading = true + filesStoreMock.selectFile.mockReset() + sidebarStoreMock.activeRequestSignatureTab.mockReset() }) function createWrapper() { @@ -160,4 +137,17 @@ describe('FileEntryGrid.vue', () => { expect(checkbox.props('isLoading')).toBe(true) expect(checkbox.props('source')).toMatchObject({ id: 7 }) }) + + it('opens the details sidebar for the selected file', () => { + const wrapper = createWrapper() + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as Event + + wrapper.vm.openDetailsIfAvailable(event) + + expect(filesStoreMock.selectFile).toHaveBeenCalledWith(7) + expect(sidebarStoreMock.activeRequestSignatureTab).toHaveBeenCalled() + }) }) diff --git a/src/views/Documents/IdDocsValidation.vue b/src/views/Documents/IdDocsValidation.vue index dce6d2d207..8b574970f0 100644 --- a/src/views/Documents/IdDocsValidation.vue +++ b/src/views/Documents/IdDocsValidation.vue @@ -169,8 +169,22 @@
-