diff --git a/src/components/PdfEditor/PdfEditor.spec.js b/src/components/PdfEditor/PdfEditor.spec.js new file mode 100644 index 0000000000..25338eb733 --- /dev/null +++ b/src/components/PdfEditor/PdfEditor.spec.js @@ -0,0 +1,717 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import PdfEditor from './PdfEditor.vue' + +const pdfElementsMethods = vi.hoisted(() => ({ + startAddingElement: vi.fn(), + cancelAdding: vi.fn(), + addObjectToPage: vi.fn(), + updateObject: vi.fn(), + adjustZoomToFit: vi.fn(), + getPageHeight: vi.fn(() => 841.89), +})) + +// Mock pdf-elements component +const mockPdfElements = vi.hoisted(() => ({ + name: 'PDFElements', + render(h) { + return h('div', { class: 'pdf-elements-mock' }) + }, + methods: pdfElementsMethods, +})) + +vi.mock('@libresign/pdf-elements/src/components/PDFElements.vue', () => ({ + default: mockPdfElements, +})) + +vi.mock('../../helpers/pdfWorker.js', () => ({ + ensurePdfWorker: vi.fn(), +})) + +describe('PdfEditor Component - Business Rules', () => { + let wrapper + + beforeEach(() => { + vi.clearAllMocks() + wrapper = mount(PdfEditor, { + props: { + files: [], + fileNames: [], + readOnly: false, + signers: [], + }, + global: { + stubs: { + NcButton: true, + NcIconSvgWrapper: true, + PDFElements: mockPdfElements, + SignerMenu: true, + SignatureBox: true, + }, + }, + }) + }) + + describe('RULE: getSignerLabel with fallback chain', () => { + it('returns displayName when available', () => { + const signer = { + displayName: 'John Doe', + name: 'johndoe', + email: 'john@example.com', + id: 123, + } + + expect(wrapper.vm.getSignerLabel(signer)).toBe('John Doe') + }) + + it('falls back to name when displayName not available', () => { + const signer = { + name: 'johndoe', + email: 'john@example.com', + id: 123, + } + + expect(wrapper.vm.getSignerLabel(signer)).toBe('johndoe') + }) + + it('falls back to email when name not available', () => { + const signer = { + email: 'john@example.com', + id: 123, + } + + expect(wrapper.vm.getSignerLabel(signer)).toBe('john@example.com') + }) + + it('falls back to id when email not available', () => { + const signer = { + id: 123, + } + + expect(wrapper.vm.getSignerLabel(signer)).toBe(123) + }) + + it('returns empty string when signer is null', () => { + expect(wrapper.vm.getSignerLabel(null)).toBe('') + }) + + it('returns empty string when signer is undefined', () => { + expect(wrapper.vm.getSignerLabel(undefined)).toBe('') + }) + + it('returns empty string when no identifiable fields', () => { + const signer = {} + + expect(wrapper.vm.getSignerLabel(signer)).toBe('') + }) + }) + + describe('RULE: hasMultipleSigners detection', () => { + it('returns false when no signers', () => { + expect(wrapper.vm.hasMultipleSigners).toBe(false) + }) + + it('returns false when one signer', async () => { + await wrapper.setProps({ + signers: [{ email: 'test@example.com' }], + }) + + expect(wrapper.vm.hasMultipleSigners).toBe(false) + }) + + it('returns true when multiple signers', async () => { + await wrapper.setProps({ + signers: [ + { email: 'test1@example.com' }, + { email: 'test2@example.com' }, + ], + }) + + expect(wrapper.vm.hasMultipleSigners).toBe(true) + }) + + it('returns false when signers is null', async () => { + await wrapper.setProps({ signers: null }) + + expect(wrapper.vm.hasMultipleSigners).toBe(false) + }) + }) + + describe('RULE: startAddingSigner validation', () => { + beforeEach(() => { + wrapper.vm.$refs.pdfElements = pdfElementsMethods + }) + + it('returns false when pdfElements not available', () => { + wrapper.vm.$refs.pdfElements = null + + const result = wrapper.vm.startAddingSigner( + { email: 'test@example.com' }, + { width: 200, height: 100 }, + ) + + expect(result).toBe(false) + }) + + it('returns false when size has no width', () => { + const result = wrapper.vm.startAddingSigner( + { email: 'test@example.com' }, + { height: 100 }, + ) + + expect(result).toBe(false) + }) + + it('returns false when size has no height', () => { + const result = wrapper.vm.startAddingSigner( + { email: 'test@example.com' }, + { width: 200 }, + ) + + expect(result).toBe(false) + }) + + it('returns true and starts adding when valid params', () => { + const signer = { email: 'test@example.com' } + const size = { width: 200, height: 100 } + + const result = wrapper.vm.startAddingSigner(signer, size) + + expect(result).toBe(true) + expect(pdfElementsMethods.startAddingElement).toHaveBeenCalledWith({ + type: 'signature', + width: 200, + height: 100, + signer: expect.objectContaining({ + email: 'test@example.com', + element: {}, + }), + }) + }) + + it('preserves existing element data when adding', () => { + const signer = { + email: 'test@example.com', + element: { elementId: 123, signRequestId: 456 }, + } + const size = { width: 200, height: 100 } + + wrapper.vm.startAddingSigner(signer, size) + + expect(pdfElementsMethods.startAddingElement).toHaveBeenCalledWith( + expect.objectContaining({ + signer: expect.objectContaining({ + element: expect.objectContaining({ + elementId: 123, + signRequestId: 456, + }), + }), + }), + ) + }) + }) + + describe('RULE: addSigner coordinate calculations', () => { + beforeEach(() => { + wrapper.vm.$refs.pdfElements = { + ...pdfElementsMethods, + selectedDocIndex: 0, + getPageHeight: vi.fn(() => 841.89), + addObjectToPage: vi.fn(), + } + }) + + it('converts coordinates from left/top format', () => { + const signer = { + element: { + documentIndex: 0, + coordinates: { + page: 1, + left: 100, + top: 200, + width: 300, + height: 150, + }, + }, + } + + wrapper.vm.addSigner(signer) + + expect(wrapper.vm.$refs.pdfElements.addObjectToPage).toHaveBeenCalledWith( + expect.objectContaining({ + x: 100, + y: 200, + width: 300, + height: 150, + }), + 0, // pageIndex + 0, // docIndex + ) + }) + + it('converts coordinates from llx/lly/urx/ury format to x/y', () => { + const signer = { + element: { + documentIndex: 0, + coordinates: { + page: 1, + llx: 50, + lly: 100, + urx: 250, + ury: 300, + }, + }, + } + + wrapper.vm.addSigner(signer) + + const call = wrapper.vm.$refs.pdfElements.addObjectToPage.mock.calls[0][0] + + expect(call.width).toBe(200) // urx - llx = 250 - 50 + expect(call.height).toBe(200) // ury - lly = 300 - 100 + expect(call.x).toBe(50) // llx + }) + + it('calculates y from ury when using PDF coordinates', () => { + const pageHeight = 841.89 + wrapper.vm.$refs.pdfElements.getPageHeight.mockReturnValue(pageHeight) + + const signer = { + element: { + documentIndex: 0, + coordinates: { + page: 1, + llx: 50, + lly: 100, + urx: 250, + ury: 700, + }, + }, + } + + wrapper.vm.addSigner(signer) + + const call = wrapper.vm.$refs.pdfElements.addObjectToPage.mock.calls[0][0] + + // y = pageHeight - ury = 841.89 - 700 + expect(call.y).toBeCloseTo(141.89, 2) + }) + + it('uses default coordinates when missing', () => { + const signer = { + element: { + documentIndex: 0, + coordinates: { + page: 1, + }, + }, + } + + wrapper.vm.addSigner(signer) + + expect(wrapper.vm.$refs.pdfElements.addObjectToPage).toHaveBeenCalledWith( + expect.objectContaining({ + x: 0, + y: 0, + width: 0, + height: 0, + }), + 0, + 0, + ) + }) + + it('uses correct page index (page - 1)', () => { + const signer = { + element: { + documentIndex: 0, + coordinates: { + page: 5, // 1-indexed + left: 0, + top: 0, + width: 100, + height: 50, + }, + }, + } + + wrapper.vm.addSigner(signer) + + expect(wrapper.vm.$refs.pdfElements.addObjectToPage).toHaveBeenCalledWith( + expect.anything(), + 4, // 0-indexed: page 5 = index 4 + 0, + ) + }) + + it('uses selectedDocIndex when documentIndex not specified', () => { + wrapper.vm.$refs.pdfElements.selectedDocIndex = 2 + + const signer = { + element: { + coordinates: { + page: 1, + left: 0, + top: 0, + width: 100, + height: 50, + }, + }, + } + + wrapper.vm.addSigner(signer) + + expect(wrapper.vm.$refs.pdfElements.addObjectToPage).toHaveBeenCalledWith( + expect.anything(), + 0, + 2, // uses selectedDocIndex + ) + }) + + it('generates unique object ID', () => { + const signer = { + element: { + documentIndex: 0, + coordinates: { page: 1 }, + }, + } + + wrapper.vm.addSigner(signer) + + const call = wrapper.vm.$refs.pdfElements.addObjectToPage.mock.calls[0][0] + + expect(call.id).toMatch(/^obj-\d+-[a-z0-9]{6}$/) + }) + + it('includes signer data in object', () => { + const signer = { + email: 'test@example.com', + displayName: 'Test User', + element: { + documentIndex: 0, + coordinates: { page: 1 }, + }, + } + + wrapper.vm.addSigner(signer) + + expect(wrapper.vm.$refs.pdfElements.addObjectToPage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'signature', + signer, + }), + expect.anything(), + expect.anything(), + ) + }) + }) + + describe('RULE: findObjectLocation in documents', () => { + beforeEach(() => { + wrapper.vm.$refs.pdfElements = { + pdfDocuments: [ + { + allObjects: [ + [{ id: 'obj-1' }, { id: 'obj-2' }], + [{ id: 'obj-3' }], + ], + }, + { + allObjects: [ + [{ id: 'obj-4' }], + [{ id: 'obj-5' }, { id: 'obj-6' }], + ], + }, + ], + } + }) + + it('finds object in first document, first page', () => { + const location = wrapper.vm.findObjectLocation( + wrapper.vm.$refs.pdfElements, + 'obj-1', + ) + + expect(location).toEqual({ docIndex: 0, pageIndex: 0 }) + }) + + it('finds object in first document, second page', () => { + const location = wrapper.vm.findObjectLocation( + wrapper.vm.$refs.pdfElements, + 'obj-3', + ) + + expect(location).toEqual({ docIndex: 0, pageIndex: 1 }) + }) + + it('finds object in second document', () => { + const location = wrapper.vm.findObjectLocation( + wrapper.vm.$refs.pdfElements, + 'obj-5', + ) + + expect(location).toEqual({ docIndex: 1, pageIndex: 1 }) + }) + + it('returns null when object not found', () => { + const location = wrapper.vm.findObjectLocation( + wrapper.vm.$refs.pdfElements, + 'obj-999', + ) + + expect(location).toBe(null) + }) + + it('returns null when pdfElements is null', () => { + const location = wrapper.vm.findObjectLocation(null, 'obj-1') + + expect(location).toBe(null) + }) + + it('returns null when pdfDocuments is empty', () => { + wrapper.vm.$refs.pdfElements.pdfDocuments = [] + + const location = wrapper.vm.findObjectLocation( + wrapper.vm.$refs.pdfElements, + 'obj-1', + ) + + expect(location).toBe(null) + }) + }) + + describe('RULE: onSignerChange updates object signer', () => { + beforeEach(async () => { + await wrapper.setProps({ + signers: [ + { signRequestId: 1, email: 'signer1@example.com', displayName: 'Signer 1' }, + { signRequestId: 2, email: 'signer2@example.com', displayName: 'Signer 2' }, + { signRequestId: 3, email: 'signer3@example.com', displayName: 'Signer 3' }, + ], + }) + + wrapper.vm.$refs.pdfElements = { + ...mockPdfElements, + updateObject: vi.fn(), + pdfDocuments: [ + { + allObjects: [ + [{ id: 'obj-1', signer: { signRequestId: 1 } }], + ], + }, + ], + } + }) + + it('does nothing when object is null', () => { + wrapper.vm.onSignerChange(null, { signRequestId: 2 }) + + expect(wrapper.vm.$refs.pdfElements.updateObject).not.toHaveBeenCalled() + }) + + it('does nothing when signer is null', () => { + const object = { id: 'obj-1', signer: { signRequestId: 1 } } + + wrapper.vm.onSignerChange(object, null) + + expect(wrapper.vm.$refs.pdfElements.updateObject).not.toHaveBeenCalled() + }) + + it('updates object with new signer from signers list', () => { + const object = { + id: 'obj-1', + signer: { signRequestId: 1, element: { elementId: 123 } }, + } + const newSigner = { signRequestId: 2 } + + wrapper.vm.onSignerChange(object, newSigner) + + expect(wrapper.vm.$refs.pdfElements.updateObject).toHaveBeenCalledWith( + 0, // docIndex + 'obj-1', + { + signer: expect.objectContaining({ + signRequestId: 2, + email: 'signer2@example.com', + displayName: 'Signer 2', + }), + }, + ) + }) + + it('preserves element data when changing signer', () => { + const object = { + id: 'obj-1', + signer: { + signRequestId: 1, + element: { elementId: 123, coordinates: { page: 1 } }, + }, + } + const newSigner = { signRequestId: 2 } + + wrapper.vm.onSignerChange(object, newSigner) + + expect(wrapper.vm.$refs.pdfElements.updateObject).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + signer: expect.objectContaining({ + element: expect.objectContaining({ + elementId: 123, + signRequestId: 2, // updated + }), + }), + }, + ) + }) + + it('uses email as identifier when signRequestId not available', async () => { + await wrapper.setProps({ + signers: [ + { email: 'signer1@example.com' }, + { email: 'signer2@example.com' }, + ], + }) + + const object = { + id: 'obj-1', + signer: { email: 'signer1@example.com' }, + } + + wrapper.vm.onSignerChange(object, { email: 'signer2@example.com' }) + + expect(wrapper.vm.$refs.pdfElements.updateObject).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + signer: expect.objectContaining({ + email: 'signer2@example.com', + }), + }, + ) + }) + + it('does nothing when target signer not found in signers list', () => { + const object = { id: 'obj-1', signer: { signRequestId: 1 } } + + wrapper.vm.onSignerChange(object, { signRequestId: 999 }) + + expect(wrapper.vm.$refs.pdfElements.updateObject).not.toHaveBeenCalled() + }) + }) + + describe('RULE: event emissions', () => { + it('emits pdf-editor:on-delete-signer when deleting signature object', () => { + const object = { id: 'obj-1', signer: { email: 'test@example.com' } } + + wrapper.vm.handleDeleteObject({ object }) + + expect(wrapper.emitted('pdf-editor:on-delete-signer')).toBeTruthy() + expect(wrapper.emitted('pdf-editor:on-delete-signer')[0][0]).toEqual(object) + }) + + it('does not emit delete event when object has no signer', () => { + const object = { id: 'obj-1' } + + wrapper.vm.handleDeleteObject({ object }) + + expect(wrapper.emitted('pdf-editor:on-delete-signer')).toBeFalsy() + }) + + it('emits pdf-editor:object-click when object clicked', () => { + const event = { object: { id: 'obj-1' } } + + wrapper.vm.handleObjectClick(event) + + expect(wrapper.emitted('pdf-editor:object-click')).toBeTruthy() + expect(wrapper.emitted('pdf-editor:object-click')[0][0]).toEqual(event) + }) + }) + + describe('RULE: cancelAdding method', () => { + it('calls pdfElements cancelAdding when available', () => { + wrapper.vm.$refs.pdfElements = pdfElementsMethods + + wrapper.vm.cancelAdding() + + expect(pdfElementsMethods.cancelAdding).toHaveBeenCalled() + }) + + it('does not error when pdfElements not available', () => { + wrapper.vm.$refs.pdfElements = null + + expect(() => wrapper.vm.cancelAdding()).not.toThrow() + }) + }) + + describe('RULE: readOnly prop behavior', () => { + it('passes readOnly to PDFElements', async () => { + await wrapper.setProps({ readOnly: true }) + + // Would need to check the PDFElements component props + // This is more of an integration test + }) + }) + + describe('RULE: coordinate system conversion', () => { + beforeEach(() => { + wrapper.vm.$refs.pdfElements = { + ...mockPdfElements, + selectedDocIndex: 0, + getPageHeight: vi.fn(() => 841.89), // A4 height in points + addObjectToPage: vi.fn(), + } + }) + + it('handles bottom-left origin PDF coordinates correctly', () => { + // PDF uses bottom-left origin, web uses top-left + const pageHeight = 841.89 + const signer = { + element: { + documentIndex: 0, + coordinates: { + page: 1, + lly: 100, // 100 points from bottom + ury: 200, // 200 points from bottom + llx: 50, + urx: 250, + }, + }, + } + + wrapper.vm.addSigner(signer) + + const call = wrapper.vm.$refs.pdfElements.addObjectToPage.mock.calls[0][0] + + // y should be: pageHeight - ury = 841.89 - 200 = 641.89 + expect(call.y).toBeCloseTo(641.89, 2) + expect(call.height).toBe(100) // ury - lly + }) + + it('ensures y coordinate never negative', () => { + const signer = { + element: { + documentIndex: 0, + coordinates: { + page: 1, + ury: 900, // beyond page height + lly: 800, + llx: 0, + urx: 100, + }, + }, + } + + wrapper.vm.addSigner(signer) + + const call = wrapper.vm.$refs.pdfElements.addObjectToPage.mock.calls[0][0] + + expect(call.y).toBeGreaterThanOrEqual(0) + }) + }) +}) diff --git a/src/components/Request/VisibleElements.spec.js b/src/components/Request/VisibleElements.spec.js new file mode 100644 index 0000000000..9b0ce527f8 --- /dev/null +++ b/src/components/Request/VisibleElements.spec.js @@ -0,0 +1,425 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import VisibleElements from './VisibleElements.vue' +import { FILE_STATUS } from '../../constants.js' + +vi.mock('@nextcloud/capabilities', () => ({ + getCapabilities: vi.fn(() => ({ + libresign: { + config: { + 'sign-elements': { + 'is-available': true, + 'full-signature-width': 200, + 'full-signature-height': 100, + }, + }, + }, + })), +})) + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn((app, key, defaultValue) => { + if (key === 'can_request_sign') return true + return defaultValue + }), +})) + +vi.mock('@nextcloud/event-bus', () => ({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), +})) + +vi.mock('@nextcloud/axios') +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path) => `/ocs${path}`), +})) + +vi.mock('@libresign/pdf-elements/src/utils/asyncReader.js', () => ({ + setWorkerPath: vi.fn(), +})) + +describe('VisibleElements Component - Business Rules', () => { + let wrapper + let filesStore + + beforeEach(async () => { + setActivePinia(createPinia()) + const { useFilesStore } = await import('../../store/files.js') + filesStore = useFilesStore() + + filesStore.files[1] = { + id: 1, + name: 'test.pdf', + status: FILE_STATUS.DRAFT, + statusText: 'Draft', + signers: [], + files: [], + } + filesStore.selectedFileId = 1 + + wrapper = mount(VisibleElements, { + global: { + stubs: { + NcModal: true, + NcNoteCard: true, + NcChip: true, + NcButton: true, + NcLoadingIcon: true, + PdfEditor: true, + Signer: true, + }, + }, + }) + }) + + describe('RULE: canSign depends on status and signer UUID', () => { + it('returns false when status is not ABLE_TO_SIGN', () => { + filesStore.files[1].status = FILE_STATUS.DRAFT + filesStore.files[1].settings = { signerFileUuid: 'valid-uuid' } + + expect(wrapper.vm.canSign).toBe(false) + }) + + it('returns false when status is ABLE_TO_SIGN but no signerFileUuid', () => { + filesStore.files[1].status = FILE_STATUS.ABLE_TO_SIGN + filesStore.files[1].settings = {} + + expect(wrapper.vm.canSign).toBe(false) + }) + + it('returns true when status is ABLE_TO_SIGN and has signerFileUuid', () => { + filesStore.files[1].status = FILE_STATUS.ABLE_TO_SIGN + filesStore.files[1].settings = { signerFileUuid: 'valid-uuid' } + + expect(wrapper.vm.canSign).toBe(true) + }) + }) + + describe('RULE: canSave allows specific statuses', () => { + it('allows saving when status is DRAFT', () => { + filesStore.files[1].status = FILE_STATUS.DRAFT + + expect(wrapper.vm.canSave).toBe(true) + }) + + it('allows saving when status is ABLE_TO_SIGN', () => { + filesStore.files[1].status = FILE_STATUS.ABLE_TO_SIGN + + expect(wrapper.vm.canSave).toBe(true) + }) + + it('allows saving when status is PARTIAL_SIGNED', () => { + filesStore.files[1].status = FILE_STATUS.PARTIAL_SIGNED + + expect(wrapper.vm.canSave).toBe(true) + }) + + it('blocks saving when status is SIGNED', () => { + filesStore.files[1].status = FILE_STATUS.SIGNED + + expect(wrapper.vm.canSave).toBe(false) + }) + + it('blocks saving when status is DELETED', () => { + filesStore.files[1].status = FILE_STATUS.DELETED + + expect(wrapper.vm.canSave).toBe(false) + }) + }) + + describe('RULE: buildFilePagesMap creates correct page mappings', () => { + it('maps single file pages correctly', () => { + filesStore.files[1].files = [ + { + id: 10, + name: 'doc1.pdf', + metadata: { p: 3 }, + }, + ] + + wrapper.vm.buildFilePagesMap() + + expect(wrapper.vm.filePagesMap[1]).toEqual({ + id: 10, + fileIndex: 0, + startPage: 1, + fileName: 'doc1.pdf', + }) + expect(wrapper.vm.filePagesMap[2]).toEqual({ + id: 10, + fileIndex: 0, + startPage: 1, + fileName: 'doc1.pdf', + }) + expect(wrapper.vm.filePagesMap[3]).toEqual({ + id: 10, + fileIndex: 0, + startPage: 1, + fileName: 'doc1.pdf', + }) + }) + + it('maps multiple files pages sequentially', () => { + filesStore.files[1].files = [ + { id: 10, name: 'doc1.pdf', metadata: { p: 2 } }, + { id: 20, name: 'doc2.pdf', metadata: { p: 3 } }, + { id: 30, name: 'doc3.pdf', metadata: { p: 1 } }, + ] + + wrapper.vm.buildFilePagesMap() + + // First file: pages 1-2 + expect(wrapper.vm.filePagesMap[1].id).toBe(10) + expect(wrapper.vm.filePagesMap[2].id).toBe(10) + + // Second file: pages 3-5 + expect(wrapper.vm.filePagesMap[3].id).toBe(20) + expect(wrapper.vm.filePagesMap[3].startPage).toBe(3) + expect(wrapper.vm.filePagesMap[5].id).toBe(20) + + // Third file: page 6 + expect(wrapper.vm.filePagesMap[6].id).toBe(30) + expect(wrapper.vm.filePagesMap[6].startPage).toBe(6) + }) + + it('handles files with zero pages', () => { + filesStore.files[1].files = [ + { id: 10, name: 'doc1.pdf', metadata: { p: 0 } }, + { id: 20, name: 'doc2.pdf', metadata: { p: 2 } }, + ] + + wrapper.vm.buildFilePagesMap() + + // First file adds no pages, second file starts at page 1 + expect(wrapper.vm.filePagesMap[1]).toBeDefined() + expect(wrapper.vm.filePagesMap[1].id).toBe(20) + }) + + it('handles empty files array', () => { + filesStore.files[1].files = [] + + wrapper.vm.buildFilePagesMap() + + expect(wrapper.vm.filePagesMap).toEqual({}) + }) + + it('handles missing files property', () => { + delete filesStore.files[1].files + + wrapper.vm.buildFilePagesMap() + + expect(wrapper.vm.filePagesMap).toEqual({}) + }) + }) + + describe('RULE: status computation and display', () => { + it('computes status from document', () => { + filesStore.files[1].status = FILE_STATUS.ABLE_TO_SIGN + + expect(wrapper.vm.status).toBe(FILE_STATUS.ABLE_TO_SIGN) + }) + + it('defaults to -1 when status missing', () => { + delete filesStore.files[1].status + + expect(wrapper.vm.status).toBe(-1) + }) + + it('identifies draft status', () => { + filesStore.files[1].status = FILE_STATUS.DRAFT + + expect(wrapper.vm.isDraft).toBe(true) + }) + + it('identifies non-draft status', () => { + filesStore.files[1].status = FILE_STATUS.ABLE_TO_SIGN + + expect(wrapper.vm.isDraft).toBe(false) + }) + }) + + describe('RULE: button variants based on permissions', () => { + it('save button is primary when canSave is true', () => { + filesStore.files[1].status = FILE_STATUS.DRAFT + + expect(wrapper.vm.variantOfSaveButton).toBe('primary') + }) + + it('save button is secondary when canSave is false', () => { + filesStore.files[1].status = FILE_STATUS.SIGNED + + expect(wrapper.vm.variantOfSaveButton).toBe('secondary') + }) + + it('sign button is secondary when canSave is true', () => { + filesStore.files[1].status = FILE_STATUS.DRAFT + + expect(wrapper.vm.variantOfSignButton).toBe('secondary') + }) + + it('sign button is primary when canSave is false', () => { + filesStore.files[1].status = FILE_STATUS.SIGNED + + expect(wrapper.vm.variantOfSignButton).toBe('primary') + }) + }) + + describe('RULE: PDF file name generation', () => { + it('generates file names with extension', () => { + filesStore.files[1].files = [ + { name: 'doc1', metadata: { extension: 'pdf' } }, + { name: 'doc2', metadata: { extension: 'docx' } }, + ] + + expect(wrapper.vm.pdfFileNames).toEqual(['doc1.pdf', 'doc2.docx']) + }) + + it('defaults to pdf extension when not specified', () => { + filesStore.files[1].files = [ + { name: 'doc1', metadata: {} }, + ] + + expect(wrapper.vm.pdfFileNames).toEqual(['doc1.pdf']) + }) + + it('handles missing metadata', () => { + filesStore.files[1].files = [ + { name: 'doc1' }, + ] + + expect(wrapper.vm.pdfFileNames).toEqual(['doc1.pdf']) + }) + }) + + describe('RULE: PDF files extraction', () => { + it('extracts file objects from files array', () => { + const file1 = { path: '/path/to/file1.pdf' } + const file2 = { path: '/path/to/file2.pdf' } + + filesStore.files[1].files = [ + { name: 'doc1', file: file1 }, + { name: 'doc2', file: file2 }, + ] + + expect(wrapper.vm.pdfFiles).toEqual([file1, file2]) + }) + + it('filters out files without file object', () => { + const file1 = { path: '/path/to/file1.pdf' } + + filesStore.files[1].files = [ + { name: 'doc1', file: file1 }, + { name: 'doc2', file: null }, + { name: 'doc3' }, + ] + + expect(wrapper.vm.pdfFiles).toEqual([file1]) + }) + + it('returns empty array when no files', () => { + filesStore.files[1].files = [] + + expect(wrapper.vm.pdfFiles).toEqual([]) + }) + }) + + describe('RULE: page height retrieval', () => { + it('retrieves correct page height from file metadata', () => { + filesStore.files[1].files = [ + { + id: 10, + metadata: { + d: [ + { h: 841.89 }, + { h: 600.0 }, + { h: 792.0 }, + ], + }, + }, + ] + + expect(wrapper.vm.getPageHeightForFile(10, 1)).toBe(841.89) + expect(wrapper.vm.getPageHeightForFile(10, 2)).toBe(600.0) + expect(wrapper.vm.getPageHeightForFile(10, 3)).toBe(792.0) + }) + + it('returns undefined for non-existent file', () => { + filesStore.files[1].files = [ + { id: 10, metadata: { d: [{ h: 841.89 }] } }, + ] + + expect(wrapper.vm.getPageHeightForFile(999, 1)).toBeUndefined() + }) + + it('returns undefined for non-existent page', () => { + filesStore.files[1].files = [ + { id: 10, metadata: { d: [{ h: 841.89 }] } }, + ] + + expect(wrapper.vm.getPageHeightForFile(10, 5)).toBeUndefined() + }) + }) + + describe('RULE: signer selection state management', () => { + it('initializes with no signer selected', () => { + expect(wrapper.vm.signerSelected).toBe(null) + }) + + it('stops adding signer clears selection', () => { + wrapper.vm.signerSelected = { email: 'test@example.com' } + + wrapper.vm.stopAddSigner() + + expect(wrapper.vm.signerSelected).toBe(null) + }) + }) + + describe('RULE: modal state management', () => { + it('initializes with modal closed', () => { + expect(wrapper.vm.modal).toBe(false) + }) + + it('closeModal resets all modal state', () => { + wrapper.vm.modal = true + wrapper.vm.elementsLoaded = true + wrapper.vm.signerSelected = { email: 'test@example.com' } + filesStore.loading = true + + wrapper.vm.closeModal() + + expect(wrapper.vm.modal).toBe(false) + expect(wrapper.vm.elementsLoaded).toBe(false) + expect(wrapper.vm.signerSelected).toBe(null) + expect(filesStore.loading).toBe(false) + }) + }) + + describe('RULE: document name with extension', () => { + it('appends extension when available', () => { + filesStore.files[1].name = 'contract' + filesStore.files[1].metadata = { extension: 'pdf' } + + expect(wrapper.vm.documentNameWithExtension).toBe('contract.pdf') + }) + + it('returns name without extension when not available', () => { + filesStore.files[1].name = 'contract.pdf' + filesStore.files[1].metadata = {} + + expect(wrapper.vm.documentNameWithExtension).toBe('contract.pdf') + }) + + it('handles missing metadata', () => { + filesStore.files[1].name = 'contract' + delete filesStore.files[1].metadata + + expect(wrapper.vm.documentNameWithExtension).toBe('contract') + }) + }) +}) diff --git a/src/components/RightSidebar/RequestSignatureTab.spec.js b/src/components/RightSidebar/RequestSignatureTab.spec.js new file mode 100644 index 0000000000..10641b2638 --- /dev/null +++ b/src/components/RightSidebar/RequestSignatureTab.spec.js @@ -0,0 +1,563 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { shallowMount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +let RequestSignatureTab +import { FILE_STATUS } from '../../constants.js' + +// Mock translation function +global.t = vi.fn((app, msg) => msg) + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn((app, key, defaultValue) => { + if (key === 'config') { + return { + 'sign-elements': { 'is-available': true }, + 'identification_documents': { enabled: false }, + } + } + if (key === 'can_request_sign') return true + return defaultValue + }), +})) + +vi.mock('@nextcloud/capabilities', () => ({ + getCapabilities: vi.fn(() => ({ + libresign: { + config: { + 'sign-elements': { 'is-available': true }, + }, + }, + })), +})) + +vi.mock('@nextcloud/event-bus', () => ({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), + emit: vi.fn(), +})) + +vi.mock('@nextcloud/dialogs') +vi.mock('@nextcloud/axios') +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path) => `/ocs${path}`), + generateUrl: vi.fn((path) => path), + getRootUrl: vi.fn(() => ''), +})) + +vi.mock('@libresign/pdf-elements/src/utils/asyncReader.js', () => ({ + setWorkerPath: vi.fn(), +})) + +describe('RequestSignatureTab - Critical Business Rules', () => { + let wrapper + let filesStore + const updateFile = async (patch) => { + const current = filesStore.files[1] || { id: 1 } + await filesStore.addFile({ ...current, ...patch, id: 1 }) + await wrapper.vm.$nextTick() + } + const updateMethods = async (methods) => { + await wrapper.setData({ methods }) + } + + beforeEach(async () => { + setActivePinia(createPinia()) + RequestSignatureTab = (await import('./RequestSignatureTab.vue')).default + const { useFilesStore } = await import('../../store/files.js') + filesStore = useFilesStore() + + await filesStore.addFile({ + id: 1, + name: 'test.pdf', + status: FILE_STATUS.DRAFT, + signers: [], + nodeType: 'file', + }) + filesStore.selectFile(1) + filesStore.canRequestSign = true + + wrapper = shallowMount(RequestSignatureTab, { + global: { + stubs: { + NcButton: true, + NcCheckboxRadioSwitch: true, + NcNoteCard: true, + NcActionInput: true, + NcActionButton: true, + NcFormBox: true, + NcLoadingIcon: true, + Signers: true, + SigningProgress: true, + AccountPlus: true, + ChartGantt: true, + FileMultiple: true, + Send: true, + Delete: true, + Bell: true, + Draw: true, + Pencil: true, + MessageText: true, + OrderNumericAscending: true, + }, + }, + }) + }) + + describe('RULE: showDocMdpWarning when DocMDP level prevents changes', () => { + it('shows warning when DocMDP level is 1 with existing signers', async () => { + await updateFile({ + docmdpLevel: 1, + signers: [{ email: 'test@example.com', signed: [] }], + }) + + expect(wrapper.vm.showDocMdpWarning).toBe(true) + }) + + it('hides warning when DocMDP level is not 1', async () => { + await updateFile({ + docmdpLevel: 2, + signers: [{ email: 'test@example.com', signed: [] }], + }) + + expect(wrapper.vm.showDocMdpWarning).toBe(false) + }) + + it('hides warning when no signers exist', async () => { + await updateFile({ docmdpLevel: 1, signers: [] }) + + expect(wrapper.vm.showDocMdpWarning).toBe(false) + }) + }) + + describe('RULE: isOriginalFileDeleted detection', () => { + it('detects when original file is deleted', async () => { + await updateFile({ metadata: { original_file_deleted: true } }) + + expect(wrapper.vm.isOriginalFileDeleted).toBe(true) + }) + + it('returns false when file is not deleted', async () => { + await updateFile({ metadata: { original_file_deleted: false } }) + + expect(wrapper.vm.isOriginalFileDeleted).toBe(false) + }) + + it('returns false when metadata is missing', async () => { + await updateFile({ metadata: undefined }) + + expect(wrapper.vm.isOriginalFileDeleted).toBe(false) + }) + }) + + describe('RULE: isEnvelope detection', () => { + it('detects envelope by nodeType', async () => { + await updateFile({ nodeType: 'envelope' }) + + expect(wrapper.vm.isEnvelope).toBe(true) + }) + + it('returns false for regular file', async () => { + await updateFile({ nodeType: 'file' }) + + expect(wrapper.vm.isEnvelope).toBe(false) + }) + }) + + describe('RULE: envelopeFilesCount calculation', () => { + it('returns filesCount from document', async () => { + await updateFile({ filesCount: 5 }) + + expect(wrapper.vm.envelopeFilesCount).toBe(5) + }) + + it('defaults to 0 when neither available', () => { + expect(wrapper.vm.envelopeFilesCount).toBe(0) + }) + }) + + describe('RULE: hasSigners detection', () => { + it('returns true when document has signers', async () => { + await updateFile({ signers: [{ email: 'test@example.com', signed: [] }] }) + + expect(wrapper.vm.hasSigners).toBe(true) + }) + + it('returns false when signers array is empty', async () => { + await updateFile({ signers: [] }) + + expect(wrapper.vm.hasSigners).toBe(false) + }) + + it('returns false when signers is null', async () => { + await updateFile({ signers: null }) + + expect(wrapper.vm.hasSigners).toBe(false) + }) + }) + + describe('RULE: showPreserveOrder when multiple signers', () => { + it('shows when document has multiple signers', async () => { + await updateFile({ + status: FILE_STATUS.DRAFT, + signers: [ + { email: 'test1@example.com', signed: [] }, + { email: 'test2@example.com', signed: [] }, + ], + }) + + expect(wrapper.vm.showPreserveOrder).toBe(true) + }) + + it('hides when document has no signers', async () => { + await updateFile({ signers: [] }) + + expect(wrapper.vm.showPreserveOrder).toBe(false) + }) + + it('hides when document has only one signer', async () => { + await updateFile({ signers: [{ email: 'test@example.com', signed: [] }] }) + + expect(wrapper.vm.showPreserveOrder).toBe(false) + }) + }) + + describe('RULE: showViewOrderButton for ordered signatures', () => { + it('shows when signature flow is ordered_numeric', async () => { + await updateFile({ + signatureFlow: 'ordered_numeric', + signers: [ + { email: 'test1@example.com', signingOrder: 1, signed: [] }, + { email: 'test2@example.com', signingOrder: 2, signed: [] }, + ], + }) + + expect(wrapper.vm.showViewOrderButton).toBe(true) + }) + + it('shows when signature flow is 2 (numeric code)', async () => { + await updateFile({ + signatureFlow: 2, + signers: [ + { email: 'test1@example.com', signingOrder: 1, signed: [] }, + { email: 'test2@example.com', signingOrder: 2, signed: [] }, + ], + }) + + expect(wrapper.vm.showViewOrderButton).toBe(true) + }) + + it('hides when signature flow is parallel', async () => { + await updateFile({ + signatureFlow: 'parallel', + signers: [ + { email: 'test1@example.com', signed: [] }, + { email: 'test2@example.com', signed: [] }, + ], + }) + + expect(wrapper.vm.showViewOrderButton).toBe(false) + }) + + it('hides when only one signer', async () => { + await updateFile({ + signatureFlow: 'ordered_numeric', + signers: [{ email: 'test@example.com', signed: [] }], + }) + + expect(wrapper.vm.showViewOrderButton).toBe(false) + }) + }) + + describe('RULE: showSaveButton permission logic', () => { + it('shows when user can save and has signers', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.DRAFT, + signers: [{ email: 'test@example.com', signed: [] }], + }) + + expect(wrapper.vm.showSaveButton).toBe(true) + }) + + it('hides when user cannot save', async () => { + await updateFile({ + status: FILE_STATUS.SIGNED, + signers: [{ email: 'test@example.com', signed: ['sig'] }], + }) + + expect(wrapper.vm.showSaveButton).toBe(false) + }) + + it('hides when no signers', async () => { + await updateFile({ status: FILE_STATUS.DRAFT, signers: [] }) + + expect(wrapper.vm.showSaveButton).toBe(false) + }) + }) + + describe('RULE: showRequestButton permission logic', () => { + it('shows when user can save and document not requested yet', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.DRAFT, + signatureFlow: 'parallel', + signers: [{ email: 'test@example.com', signed: [], status: 0 }], + }) + + expect(wrapper.vm.showRequestButton).toBe(true) + }) + + it('hides when document is signed', async () => { + await updateFile({ + status: FILE_STATUS.SIGNED, + signers: [{ email: 'test@example.com', signed: ['sig'] }], + }) + + expect(wrapper.vm.showRequestButton).toBe(false) + }) + + it('hides when user cannot save', async () => { + filesStore.canRequestSign = false + await updateFile({ + status: FILE_STATUS.DRAFT, + signers: [{ email: 'test@example.com', signed: [] }], + }) + + expect(wrapper.vm.showRequestButton).toBe(false) + }) + }) + + describe('RULE: showSigningProgress when document active', () => { + it('shows when signingProgressStatus is SIGNING_IN_PROGRESS', async () => { + await wrapper.setData({ signingProgressStatus: FILE_STATUS.SIGNING_IN_PROGRESS }) + + expect(wrapper.vm.showSigningProgress).toBe(true) + }) + + it('hides when signingProgressStatus is not SIGNING_IN_PROGRESS', async () => { + await wrapper.setData({ signingProgressStatus: FILE_STATUS.DRAFT }) + + expect(wrapper.vm.showSigningProgress).toBe(false) + }) + }) + + describe('RULE: canEditSigningOrder when using ordered flow', () => { + beforeEach(async () => { + await updateFile({ + status: FILE_STATUS.DRAFT, + signatureFlow: 'ordered_numeric', + signers: [ + { email: 'test1@example.com', signingOrder: 1 }, + { email: 'test2@example.com', signingOrder: 2 }, + ], + }) + }) + + it('allows editing when flow is ordered_numeric', () => { + expect(wrapper.vm.canEditSigningOrder(wrapper.vm.filesStore.files[1].signers[0])).toBe(true) + }) + + it('allows editing when flow is 2 (numeric)', async () => { + await updateFile({ signatureFlow: 2 }) + + expect(wrapper.vm.canEditSigningOrder(wrapper.vm.filesStore.files[1].signers[0])).toBe(true) + }) + + it('blocks editing when signer has signed', async () => { + await updateFile({ + signers: [ + { ...filesStore.files[1].signers[0], signed: ['signature'] }, + filesStore.files[1].signers[1], + ], + }) + + expect(wrapper.vm.canEditSigningOrder(wrapper.vm.filesStore.files[1].signers[0])).toBe(false) + }) + + it('blocks editing when flow is parallel', async () => { + await updateFile({ signatureFlow: 'parallel' }) + + expect(wrapper.vm.canEditSigningOrder(wrapper.vm.filesStore.files[1].signers[0])).toBe(false) + }) + }) + + describe('RULE: canDelete signer permission', () => { + it('allows deleting unsigned signer', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.DRAFT, + signers: [{ email: 'test@example.com' }], + }) + const signer = { email: 'test@example.com' } + + expect(wrapper.vm.canDelete(signer)).toBe(true) + }) + + it('blocks deleting signed signer', () => { + const signer = { email: 'test@example.com', signed: ['signature'] } + + expect(wrapper.vm.canDelete(signer)).toBe(false) + }) + + it('blocks deleting when array has signature', () => { + const signer = { email: 'test@example.com', signed: ['sig1', 'sig2'] } + + expect(wrapper.vm.canDelete(signer)).toBe(false) + }) + }) + + describe('RULE: canRequestSignature for individual signer', () => { + it('allows request when signer unsigned and document able to sign', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.ABLE_TO_SIGN, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + status: 0, + signRequestId: 10, + }], + }) + const signer = { email: 'test@example.com', status: 0, signRequestId: 10 } + + expect(wrapper.vm.canRequestSignature(signer)).toBe(true) + }) + + it('blocks request when signer already signed', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.ABLE_TO_SIGN, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + signed: ['signature'], + status: 0, + signRequestId: 10, + }], + }) + const signer = { email: 'test@example.com', signed: ['signature'], status: 0, signRequestId: 10 } + + expect(wrapper.vm.canRequestSignature(signer)).toBe(false) + }) + + it('blocks request when document is draft', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.DRAFT, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + signed: [], + status: 0, + signRequestId: 10, + }], + }) + const signer = { email: 'test@example.com', signed: [], status: 0, signRequestId: 10 } + + expect(wrapper.vm.canRequestSignature(signer)).toBe(false) + }) + }) + + describe('RULE: canSendReminder for pending signers', () => { + it('allows reminder when signer unsigned and document able to sign', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.ABLE_TO_SIGN, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + status: 1, + signRequestId: 10, + }], + }) + const signer = { email: 'test@example.com', status: 1, signRequestId: 10 } + + expect(wrapper.vm.canSendReminder(signer)).toBe(true) + }) + + it('allows reminder when document partially signed', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.PARTIAL_SIGNED, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + status: 1, + signRequestId: 10, + }], + }) + const signer = { email: 'test@example.com', status: 1, signRequestId: 10 } + + expect(wrapper.vm.canSendReminder(signer)).toBe(true) + }) + + it('blocks reminder when signer already signed', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.ABLE_TO_SIGN, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + signed: ['signature'], + status: 1, + signRequestId: 10, + }], + }) + const signer = { email: 'test@example.com', signed: ['signature'], status: 1, signRequestId: 10 } + + expect(wrapper.vm.canSendReminder(signer)).toBe(false) + }) + + it('blocks reminder when document is draft', async () => { + filesStore.canRequestSign = true + await updateFile({ + status: FILE_STATUS.DRAFT, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + signed: [], + status: 1, + signRequestId: 10, + }], + }) + const signer = { email: 'test@example.com', signed: [], status: 1, signRequestId: 10 } + + expect(wrapper.vm.canSendReminder(signer)).toBe(false) + }) + }) + + describe('RULE: canCustomizeMessage permission', () => { + it('allows customizing message when signer unsigned', async () => { + await updateMethods([{ name: 'email', enabled: true }]) + await updateFile({ + status: FILE_STATUS.ABLE_TO_SIGN, + signatureFlow: 'parallel', + signers: [{ + email: 'test@example.com', + signRequestId: 10, + identifyMethods: [{ method: 'email' }], + status: 0, + }], + }) + const signer = { + email: 'test@example.com', + signRequestId: 10, + identifyMethods: [{ method: 'email' }], + status: 0, + } + + expect(wrapper.vm.canCustomizeMessage(signer)).toBe(true) + }) + + it('blocks customizing when signer signed', () => { + const signer = { email: 'test@example.com', signed: ['signature'] } + + expect(wrapper.vm.canCustomizeMessage(signer)).toBe(false) + }) + }) +}) diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index 8f234fd4bb..7d3eb8350a 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -775,8 +775,9 @@ export default { const file = this.filesStore.getFile() const signerOrder = signer.signingOrder || 1 + const signers = Array.isArray(file?.signers) ? file.signers : [] - const hasPendingLowerOrder = file.signers.some(s => { + const hasPendingLowerOrder = signers.some(s => { const otherOrder = s.signingOrder || 1 return otherOrder < signerOrder && !s.signed }) @@ -784,10 +785,12 @@ export default { return !hasPendingLowerOrder }, hasAnyDraftSigner(file) { - return file.signers.some(signer => signer.status === 0) + const signers = Array.isArray(file?.signers) ? file.signers : [] + return signers.some(signer => signer.status === 0) }, hasSequentialDraftSigners(file) { - const signersNotSigned = file.signers.filter(s => !s.signed) + const signers = Array.isArray(file?.signers) ? file.signers : [] + const signersNotSigned = signers.filter(s => !s.signed) if (signersNotSigned.length === 0) { return false } @@ -799,7 +802,8 @@ export default { return Math.min(...signersNotSigned.map(s => s.signingOrder || 1)) }, hasOrderDraftSigners(file, order) { - return file.signers.some(signer => { + const signers = Array.isArray(file?.signers) ? file.signers : [] + return signers.some(signer => { const signerOrder = signer.signingOrder || 1 return signerOrder === order && signer.status === 0 }) diff --git a/src/services/longPolling.js b/src/services/longPolling.js index dcfc04bf58..095dbad5e2 100644 --- a/src/services/longPolling.js +++ b/src/services/longPolling.js @@ -20,55 +20,63 @@ export const waitForFileStatusChange = async (fileId, currentStatus, timeout = 3 return response.data.ocs.data } -export const startLongPolling = (fileId, initialStatus, onUpdate, shouldStop, onError = null) => { - let isRunning = true - let currentStatus = initialStatus - let errorCount = 0 - const MAX_ERRORS = 5 - - const stopPolling = () => { - isRunning = false - } +export const createLongPolling = (options = {}) => { + return (fileId, initialStatus, onUpdate, shouldStop, onError = null) => { + let isRunning = true + let currentStatus = initialStatus + let errorCount = 0 + const MAX_ERRORS = 5 + const waitForStatusChange = options.waitForFileStatusChange || waitForFileStatusChange + const sleepFn = options.sleep || sleep + + const stopPolling = () => { + isRunning = false + } - const poll = async () => { - while (isRunning) { - if (shouldStop && shouldStop()) { - break - } + const poll = async () => { + while (isRunning) { + if (shouldStop && shouldStop()) { + break + } - try { - const data = await waitForFileStatusChange(fileId, currentStatus, 30) + try { + const data = await waitForStatusChange(fileId, currentStatus, 30) - errorCount = 0 + errorCount = 0 - if (data.status !== currentStatus) { - currentStatus = data.status - onUpdate(data) + if (data.status !== currentStatus) { + currentStatus = data.status + onUpdate(data) - if (isTerminalStatus(data.status)) { - break + if (isTerminalStatus(data.status)) { + break + } } - } - } catch (error) { - errorCount++ + } catch (error) { + errorCount++ - if (onError) { - onError(error) - } + if (onError) { + onError(error) + } - if (errorCount >= MAX_ERRORS) { - console.error('Long polling stopped after', MAX_ERRORS, 'consecutive errors') - break - } + if (errorCount >= MAX_ERRORS) { + console.error('Long polling stopped after', MAX_ERRORS, 'consecutive errors') + break + } - await sleep(3000) + await sleepFn(3000) + } } } - } - poll() + poll() + + return stopPolling + } +} - return stopPolling +export const startLongPolling = (fileId, initialStatus, onUpdate, shouldStop, onError = null, options = {}) => { + return createLongPolling(options)(fileId, initialStatus, onUpdate, shouldStop, onError) } const isTerminalStatus = (status) => { diff --git a/src/services/longPolling.spec.js b/src/services/longPolling.spec.js index 928807299d..e67996d19f 100644 --- a/src/services/longPolling.spec.js +++ b/src/services/longPolling.spec.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { generateOCSResponse } from '../test-helpers.js' const getMock = vi.fn() @@ -19,34 +19,273 @@ vi.mock('@nextcloud/router', () => ({ generateOcsUrl: generateOcsUrlMock, })) -describe('waitForFileStatusChange', () => { - it('requests status updates and returns response data', async () => { - getMock.mockResolvedValue(generateOCSResponse({ - payload: { status: 3 }, - })) - const { waitForFileStatusChange } = await import('./longPolling.js') +describe('longPolling services', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) - const result = await waitForFileStatusChange(42, 1, 15) + describe('waitForFileStatusChange', () => { + it('requests status updates and returns response data', async () => { + getMock.mockResolvedValue(generateOCSResponse({ + payload: { status: 3 }, + })) + const { waitForFileStatusChange } = await import('./longPolling.js') - expect(generateOcsUrlMock).toHaveBeenCalledWith('/apps/libresign/api/v1/file/{fileId}/wait-status', { fileId: 42 }) - expect(getMock).toHaveBeenCalledWith('/ocs/wait-status', { - params: { currentStatus: 1, timeout: 15 }, - timeout: 20000, + const result = await waitForFileStatusChange(42, 1, 15) + + expect(generateOcsUrlMock).toHaveBeenCalledWith('/apps/libresign/api/v1/file/{fileId}/wait-status', { fileId: 42 }) + expect(getMock).toHaveBeenCalledWith('/ocs/wait-status', { + params: { currentStatus: 1, timeout: 15 }, + timeout: 20000, + }) + expect(result).toEqual({ status: 3 }) + }) + + it('uses default timeout of 30 seconds', async () => { + getMock.mockClear() + getMock.mockResolvedValue(generateOCSResponse({ + payload: { status: 1 }, + })) + const { waitForFileStatusChange } = await import('./longPolling.js') + + await waitForFileStatusChange(1, '0') + + const call = getMock.mock.calls[0] + expect(call[1].params.timeout).toBe(30) + expect(call[1].timeout).toBe(35000) }) - expect(result).toEqual({ status: 3 }) }) - it('uses default timeout of 30 seconds', async () => { - getMock.mockClear() - getMock.mockResolvedValue(generateOCSResponse({ - payload: { status: 1 }, - })) - const { waitForFileStatusChange } = await import('./longPolling.js') + describe('startLongPolling - core business rules', () => { + it('RULE: polls continuously until terminal status reached', async () => { + const onUpdate = vi.fn() + getMock + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 2 } })) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 3 } })) // terminal + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 0, (data) => { + onUpdate(data) + }) + + // Wait for polling to complete + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2) + }, { timeout: 3000 }) + + expect(onUpdate).toHaveBeenCalledWith({ status: 2 }) + expect(onUpdate).toHaveBeenCalledWith({ status: 3 }) + }) + + it('RULE: stops polling when shouldStop returns true', async () => { + const onUpdate = vi.fn() + let shouldStopFlag = false + const shouldStop = () => shouldStopFlag + + getMock + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 2 } })) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 3 } })) + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 0, (data) => { + onUpdate(data) + if (data.status === 2) { + shouldStopFlag = true + } + }, shouldStop) + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1) + }, { timeout: 3000 }) + + expect(onUpdate).toHaveBeenCalledWith({ status: 2 }) + }) + + it('RULE: calls stopPolling function to stop polling', async () => { + const onUpdate = vi.fn() + getMock + .mockResolvedValue(generateOCSResponse({ payload: { status: 1 } })) + + const { startLongPolling } = await import('./longPolling.js') + + let stopFn + stopFn = startLongPolling(123, 0, (data) => { + onUpdate(data) + if (data.status === 1) { + stopFn() + } + }) + + // Wait for first call + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1) + }, { timeout: 3000 }) + }) + + it('RULE: terminal status 3 (signed) stops polling', async () => { + const onUpdate = vi.fn() + getMock + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 2 } })) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 3 } })) // terminal + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 1, (data) => { + onUpdate(data) + }) + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalled() + }, { timeout: 3000 }) + + expect(onUpdate).toHaveBeenCalledWith({ status: 3 }) + // Should not continue polling after terminal status + }) + + it('RULE: terminal status 4 (deleted) stops polling', async () => { + const onUpdate = vi.fn() + getMock.mockResolvedValueOnce(generateOCSResponse({ payload: { status: 4 } })) + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 1, (data) => { + onUpdate(data) + }) + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith({ status: 4 }) + }, { timeout: 3000 }) - await waitForFileStatusChange(1, '0') + expect(onUpdate).toHaveBeenCalledWith({ status: 4 }) + }) + + it('RULE: retries on error with exponential backoff', async () => { + const onUpdate = vi.fn() + const onError = vi.fn() + + getMock + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 2 } })) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 3 } })) + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 1, (data) => { + onUpdate(data) + }, null, onError, { sleep: () => Promise.resolve() }) + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2) + }, { timeout: 3000 }) + + expect(onError).toHaveBeenCalledTimes(1) + expect(onUpdate).toHaveBeenCalledWith({ status: 2 }) + expect(onUpdate).toHaveBeenCalledWith({ status: 3 }) + }) + + it('RULE: stops after MAX_ERRORS consecutive failures', async () => { + const onUpdate = vi.fn() + const onError = vi.fn() + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + getMock.mockRejectedValue(new Error('Network error')) + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 1, onUpdate, null, (error) => { + onError(error) + }, { sleep: () => Promise.resolve() }) + + await vi.waitFor(() => { + expect(onError).toHaveBeenCalledTimes(5) + }, { timeout: 10000 }) + + expect(onError).toHaveBeenCalledTimes(5) + expect(onUpdate).not.toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + + it('RULE: resets error count after successful poll', async () => { + const onUpdate = vi.fn() + const onError = vi.fn() + + getMock + .mockRejectedValueOnce(new Error('Error 1')) + .mockRejectedValueOnce(new Error('Error 2')) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 2 } })) // success + .mockRejectedValueOnce(new Error('Error 3')) + .mockRejectedValueOnce(new Error('Error 4')) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 3 } })) // success & terminal + + const { startLongPolling } = await import('./longPolling.js') - const call = getMock.mock.calls[0] - expect(call[1].params.timeout).toBe(30) - expect(call[1].timeout).toBe(35000) + startLongPolling(123, 1, (data) => { + onUpdate(data) + }, null, onError, { sleep: () => Promise.resolve() }) + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2) + }, { timeout: 10000 }) + + // Should have recovered from errors due to successful responses + expect(onError).toHaveBeenCalledTimes(4) + expect(onUpdate).toHaveBeenCalledTimes(2) + }) + + it('RULE: does not call onUpdate when status unchanged', async () => { + const onUpdate = vi.fn() + let callCount = 0 + + getMock.mockImplementation(() => { + callCount++ + if (callCount <= 2) { + return Promise.resolve(generateOCSResponse({ payload: { status: 2 } })) // same status + } + return Promise.resolve(generateOCSResponse({ payload: { status: 3 } })) // terminal + }) + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 2, (data) => { + onUpdate(data) + }) + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1) + }, { timeout: 3000 }) + + // Should only be called when status actually changed (from 2 to 3) + expect(onUpdate).toHaveBeenCalledTimes(1) + expect(onUpdate).toHaveBeenCalledWith({ status: 3 }) + }) + + it('RULE: tracks current status across poll cycles', async () => { + const onUpdate = vi.fn() + + getMock + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 2, message: 'Processing' } })) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 5, message: 'Ready' } })) + .mockResolvedValueOnce(generateOCSResponse({ payload: { status: 3, message: 'Done' } })) + + const { startLongPolling } = await import('./longPolling.js') + + startLongPolling(123, 0, (data) => { + onUpdate(data) + }) + + await vi.waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(3) + }, { timeout: 3000 }) + + expect(onUpdate).toHaveBeenNthCalledWith(1, { status: 2, message: 'Processing' }) + expect(onUpdate).toHaveBeenNthCalledWith(2, { status: 5, message: 'Ready' }) + expect(onUpdate).toHaveBeenNthCalledWith(3, { status: 3, message: 'Done' }) + }) }) }) diff --git a/src/store/files.js b/src/store/files.js index a97035c266..e1c161baa3 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -227,6 +227,9 @@ export const useFilesStore = function(...args) { if (!Object.hasOwn(file, 'signers')) { return false } + if (!Array.isArray(file.signers)) { + return false + } return file.signers.length > 0 }, isPartialSigned(file) { @@ -234,6 +237,9 @@ export const useFilesStore = function(...args) { if (!Object.hasOwn(file, 'signers')) { return false } + if (!Array.isArray(file.signers)) { + return false + } return file.signers .filter(signer => signer.signed?.length > 0).length > 0 }, @@ -242,6 +248,9 @@ export const useFilesStore = function(...args) { if (!Object.hasOwn(file, 'signers')) { return false } + if (!Array.isArray(file.signers)) { + return false + } return file.signers.length > 0 && file.signers .filter(signer => signer.signed?.length > 0).length === file.signers.length diff --git a/src/store/files.spec.js b/src/store/files.spec.js index f069e0f484..0ad163428a 100644 --- a/src/store/files.spec.js +++ b/src/store/files.spec.js @@ -12,6 +12,7 @@ vi.mock('@nextcloud/axios', () => ({ default: { post: vi.fn(), delete: vi.fn(), + patch: vi.fn(), }, })) @@ -244,5 +245,446 @@ describe('files store - critical business rules', () => { expect(store.isDocMdpNoChangesAllowed()).toBe(true) expect(store.canAddSigner()).toBe(false) }) + + it('blocks adding signers when document is partially signed', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + signers: [ + { signed: ['signature'] }, + { signed: [] }, + ], + requested_by: { userId: 'testuser' }, + } + + expect(store.canAddSigner()).toBe(false) + }) + + it('allows adding signers when user is requester and no signatures yet', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + signers: [{ signed: [] }], + requested_by: { userId: 'testuser' }, + } + + expect(store.canAddSigner()).toBe(true) + }) + }) + + describe('RULE: validation permissions based on signature status', () => { + it('allows validation when document is partially signed', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signers: [ + { signed: ['sig1'] }, + { signed: [] }, + ], + } + + expect(store.canValidate()).toBe(true) + }) + + it('allows validation when document is fully signed', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signers: [ + { signed: ['sig1'] }, + { signed: ['sig2'] }, + ], + } + + expect(store.canValidate()).toBe(true) + }) + + it('blocks validation when no signatures present', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signers: [ + { signed: [] }, + { signed: [] }, + ], + } + + expect(store.canValidate()).toBe(false) + }) + }) + + describe('RULE: save permissions require requisite conditions', () => { + it('blocks saving when original file deleted', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + signers: [{ signed: [] }], + metadata: { original_file_deleted: true }, + } + + expect(store.canSave()).toBe(false) + }) + + it('blocks saving when no signers present', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + signers: [], + } + + expect(store.canSave()).toBe(false) + }) + + it('blocks saving when user cannot request signatures', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = false + store.files[1] = { + id: 1, + signers: [{ signed: [] }], + } + + expect(store.canSave()).toBe(false) + }) + + it('blocks saving when document partially signed', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + signers: [ + { signed: ['sig'] }, + { signed: [] }, + ], + requested_by: { userId: 'testuser' }, + } + + expect(store.canSave()).toBe(false) + }) + + it('allows saving when all conditions met', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + signers: [{ signed: [] }], + requested_by: { userId: 'testuser' }, + } + + expect(store.canSave()).toBe(true) + }) + }) + + describe('RULE: delete permissions based on ownership', () => { + it('allows deletion when user is requester', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + requested_by: { userId: 'testuser' }, + } + + expect(store.canDelete()).toBe(true) + }) + + it('blocks deletion when user is not requester', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + requested_by: { userId: 'otheruser' }, + } + + expect(store.canDelete()).toBe(false) + }) + + it('allows deletion when no requester set', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = true + store.files[1] = { + id: 1, + } + + expect(store.canDelete()).toBe(true) + }) + + it('blocks deletion when user cannot request sign', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.canRequestSign = false + store.files[1] = { + id: 1, + } + + expect(store.canDelete()).toBe(false) + }) + }) + + describe('RULE: signer operations maintain consistency', () => { + it('deleting signer updates signing order for remaining signers', async () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signatureFlow: 'ordered_numeric', + signers: [ + { identify: 'id1', signingOrder: 1 }, + { identify: 'id2', signingOrder: 2 }, + { identify: 'id3', signingOrder: 3 }, + ], + } + + axios.delete.mockResolvedValue({}) + + await store.deleteSigner({ identify: 'id2', signingOrder: 2 }) + + const remainingSigners = store.files[1].signers + expect(remainingSigners).toHaveLength(2) + expect(remainingSigners.find(s => s.identify === 'id3').signingOrder).toBe(2) + }) + + it('adding signer assigns next signing order in ordered flow', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signatureFlow: 'ordered_numeric', + signers: [ + { identify: 'id1', signingOrder: 1 }, + { identify: 'id2', signingOrder: 2 }, + ], + } + + const newSigner = { email: 'new@example.com' } + store.signerUpdate(newSigner) + + const addedSigner = store.files[1].signers.find(s => s.email === 'new@example.com') + expect(addedSigner.signingOrder).toBe(3) + }) + + it('updating existing signer replaces old signer data', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signers: [ + { email: 'test@example.com', signRequestId: 123, identify: 123 }, + ], + } + + const updatedSigner = { + email: 'updated@example.com', + signRequestId: 123, + identify: 123, + extraField: 'new', + } + store.signerUpdate(updatedSigner) + + expect(store.files[1].signers).toHaveLength(1) + expect(store.files[1].signers[0].email).toBe('updated@example.com') + expect(store.files[1].signers[0].extraField).toBe('new') + }) + }) + + describe('RULE: temporary IDs handled specially', () => { + it('identifies negative numbers as temporary IDs', () => { + const store = useFilesStore() + expect(store.isTemporaryId(-1)).toBe(true) + expect(store.isTemporaryId(-999)).toBe(true) + }) + + it('identifies envelope_ prefix as temporary ID', () => { + const store = useFilesStore() + expect(store.isTemporaryId('envelope_123')).toBe(true) + expect(store.isTemporaryId('envelope_abc')).toBe(true) + }) + + it('does not identify positive numbers as temporary', () => { + const store = useFilesStore() + expect(store.isTemporaryId(1)).toBe(false) + expect(store.isTemporaryId(999)).toBe(false) + }) + + it('does not identify other strings as temporary', () => { + const store = useFilesStore() + expect(store.isTemporaryId('file_123')).toBe(false) + expect(store.isTemporaryId('uuid-123')).toBe(false) + }) + }) + + describe('RULE: file status checks', () => { + it('identifies document with no signers', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { id: 1, signers: [] } + + expect(store.hasSigners()).toBe(false) + }) + + it('identifies document with signers', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signers: [{ email: 'test@example.com' }], + } + + expect(store.hasSigners()).toBe(true) + }) + + it('identifies fully signed document', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signers: [ + { signed: ['sig1'] }, + { signed: ['sig2'] }, + { signed: ['sig3'] }, + ], + } + + expect(store.isFullSigned()).toBe(true) + }) + + it('identifies partially signed document', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + signers: [ + { signed: ['sig1'] }, + { signed: [] }, + ], + } + + expect(store.isPartialSigned()).toBe(true) + expect(store.isFullSigned()).toBe(false) + }) + }) + + describe('RULE: signing permission with deleted file', () => { + it('blocks signing when original file deleted', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + status: 1, + signers: [{ me: true, signed: [] }], + metadata: { original_file_deleted: true }, + } + + expect(store.canSign()).toBe(false) + }) + }) + + describe('RULE: rename operations', () => { + it('successfully updates file name in store', async () => { + const store = useFilesStore() + store.files[1] = { + id: 1, + uuid: 'test-uuid', + name: 'old-name.pdf', + } + + axios.patch.mockResolvedValue({ + data: { ocs: { meta: { status: 'ok' } } }, + }) + + const result = await store.rename('test-uuid', 'new-name.pdf') + + expect(result).toBe(true) + expect(store.files[1].name).toBe('new-name.pdf') + }) + + it('returns false on rename error', async () => { + const store = useFilesStore() + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + axios.patch.mockRejectedValue(new Error('Network error')) + + const result = await store.rename('test-uuid', 'new-name.pdf') + + expect(result).toBe(false) + consoleErrorSpy.mockRestore() + }) + }) + + describe('RULE: multiple file deletion', () => { + it('deletes multiple files sequentially', async () => { + const store = useFilesStore() + store.files[1] = { id: 1, name: 'file1.pdf' } + store.files[2] = { id: 2, name: 'file2.pdf' } + store.files[3] = { id: 3, name: 'file3.pdf' } + + axios.delete.mockResolvedValue({}) + + await store.deleteMultiple([1, 2, 3], false) + + expect(store.files[1]).toBeUndefined() + expect(store.files[2]).toBeUndefined() + expect(store.files[3]).toBeUndefined() + }) + + it('sets loading state during bulk deletion', async () => { + const store = useFilesStore() + store.files[1] = { id: 1, name: 'file1.pdf' } + store.files[2] = { id: 2, name: 'file2.pdf' } + + axios.delete.mockImplementation(() => { + expect(store.loading).toBe(true) + return Promise.resolve({}) + }) + + await store.deleteMultiple([1, 2], false) + + expect(store.loading).toBe(false) + }) + }) + + describe('RULE: adding unique identifiers to signers', () => { + it('generates unique identifier for new signer', () => { + const store = useFilesStore() + const signer = { email: 'test@example.com' } + + store.addIdentifierToSigner(signer) + + expect(signer.identify).toBeDefined() + expect(typeof signer.identify).toBe('string') + }) + + it('uses signRequestId as identifier when available', () => { + const store = useFilesStore() + const signer = { email: 'test@example.com', signRequestId: 456 } + + store.addIdentifierToSigner(signer) + + expect(signer.identify).toBe(456) + }) + + it('preserves existing identifier', () => { + const store = useFilesStore() + const signer = { email: 'test@example.com', identify: 'existing-id' } + + store.addIdentifierToSigner(signer) + + expect(signer.identify).toBe('existing-id') + }) }) }) diff --git a/src/store/filters.js b/src/store/filters.js index b9d8fe2188..945da0240f 100644 --- a/src/store/filters.js +++ b/src/store/filters.js @@ -26,7 +26,6 @@ export const useFiltersStore = defineStore('filter', { try { return state.filter_status != '' ? JSON.parse(state.filter_status) : [] } catch (e) { - console.error('Erro ao converter filter_status:', e) return [] } },