|
3 | 3 | * SPDX-License-Identifier: AGPL-3.0-or-later |
4 | 4 | */ |
5 | 5 |
|
6 | | -import { describe, expect, it, beforeEach, vi } from 'vitest' |
| 6 | +import { afterEach, describe, expect, it, beforeEach, vi } from 'vitest' |
7 | 7 | import { shallowMount } from '@vue/test-utils' |
| 8 | +import axios from '@nextcloud/axios' |
| 9 | +import JSConfetti from 'js-confetti' |
8 | 10 | import Validation from '../../views/Validation.vue' |
9 | 11 |
|
| 12 | +// Mock js-confetti |
| 13 | +vi.mock('js-confetti', () => ({ |
| 14 | + default: vi.fn(), |
| 15 | +})) |
| 16 | + |
10 | 17 | // Mock @nextcloud packages |
11 | 18 | vi.mock('@nextcloud/axios', () => ({ |
12 | 19 | default: { |
@@ -125,8 +132,15 @@ vi.mock('../../utils/fileStatus.js', () => ({ |
125 | 132 |
|
126 | 133 | describe('Validation.vue - Business Logic', () => { |
127 | 134 | let wrapper!: ReturnType<typeof shallowMount> |
| 135 | + let mockAddConfetti: ReturnType<typeof vi.fn> |
128 | 136 |
|
129 | 137 | beforeEach(() => { |
| 138 | + mockAddConfetti = vi.fn() |
| 139 | + // Must use `function` syntax so vitest accepts it as a valid constructor mock |
| 140 | + vi.mocked(JSConfetti).mockImplementation(function() { |
| 141 | + return { addConfetti: mockAddConfetti } |
| 142 | + } as unknown as typeof JSConfetti) |
| 143 | + |
130 | 144 | wrapper = shallowMount(Validation, { |
131 | 145 | mocks: { |
132 | 146 | $route: mockRoute, |
@@ -469,4 +483,247 @@ describe('Validation.vue - Business Logic', () => { |
469 | 483 | expect(wrapper.vm.EXPIRATION_WARNING_DAYS).toBe(30) |
470 | 484 | }) |
471 | 485 | }) |
| 486 | + |
| 487 | + // Vue Router 5 only preserves params that are part of the route path. |
| 488 | + // Routes like /f/validation/:uuid only have :uuid in the path. |
| 489 | + // State flags (isAfterSigned, isAsync) must travel via history.state, |
| 490 | + // not via route params — otherwise Vue Router 5 silently drops them. |
| 491 | + describe('isAfterSigned computed property - reads from history.state', () => { |
| 492 | + afterEach(() => { |
| 493 | + history.replaceState({}, '') |
| 494 | + }) |
| 495 | + |
| 496 | + it('returns true when history.state.isAfterSigned is true', () => { |
| 497 | + history.pushState({ isAfterSigned: true }, '') |
| 498 | + expect(wrapper.vm.isAfterSigned).toBe(true) |
| 499 | + }) |
| 500 | + |
| 501 | + it('returns false when history.state.isAfterSigned is false', () => { |
| 502 | + history.pushState({ isAfterSigned: false }, '') |
| 503 | + expect(wrapper.vm.isAfterSigned).toBe(false) |
| 504 | + }) |
| 505 | + |
| 506 | + it('falls back to shouldFireAsyncConfetti when history.state has no isAfterSigned', async () => { |
| 507 | + history.pushState({}, '') |
| 508 | + await wrapper.setData({ shouldFireAsyncConfetti: true }) |
| 509 | + expect(wrapper.vm.isAfterSigned).toBe(true) |
| 510 | + }) |
| 511 | + |
| 512 | + it('returns false when history state has no isAfterSigned and shouldFireAsyncConfetti is false', () => { |
| 513 | + history.pushState({}, '') |
| 514 | + expect(wrapper.vm.isAfterSigned).toBe(false) |
| 515 | + }) |
| 516 | + }) |
| 517 | + |
| 518 | + // Vue Router 5 drops non-path params on navigation. isAsync must travel |
| 519 | + // via history.state so it survives the push from the signing page. |
| 520 | + describe('created() - async signing activation from history.state', () => { |
| 521 | + const UUID = '550e8400-e29b-41d4-a716-446655440000' |
| 522 | + let stateGetter: ReturnType<typeof vi.spyOn> |
| 523 | + |
| 524 | + beforeEach(() => { |
| 525 | + // Prevent the validate() floating Promise from crashing on |
| 526 | + // the undefined-return of the axios.get mock |
| 527 | + vi.mocked(axios.get).mockResolvedValue({ data: { ocs: { data: {} } } }) |
| 528 | + }) |
| 529 | + |
| 530 | + afterEach(() => { |
| 531 | + stateGetter?.mockRestore() |
| 532 | + vi.mocked(axios.get).mockReset() |
| 533 | + }) |
| 534 | + |
| 535 | + // REGRESSION TEST: before the fix, Validation.vue checked $route.params.isAsync. |
| 536 | + // Vue Router 5 silently drops params not in the route path, so that check |
| 537 | + // was always false — confetti never fired. |
| 538 | + // After the fix, isAsync is read from history.state (passed via router's `state:`). |
| 539 | + // This test verifies the OLD trigger (route params) no longer activates async signing. |
| 540 | + it('does NOT set isAsyncSigning via $route.params (Vue Router 5 drops non-path params)', () => { |
| 541 | + stateGetter = vi.spyOn(window.history, 'state', 'get').mockReturnValue({} as any) |
| 542 | + const localWrapper = shallowMount(Validation, { |
| 543 | + mocks: { |
| 544 | + $route: { params: { uuid: UUID, isAsync: true }, query: {} }, |
| 545 | + $router: { ...mockRouter, replace: vi.fn() }, |
| 546 | + }, |
| 547 | + }) |
| 548 | + // $route.params.isAsync is true in the mock, BUT the fixed code no longer reads |
| 549 | + // from params — it reads from history.state (which is empty here). |
| 550 | + expect(localWrapper.vm.isAsyncSigning).toBe(false) |
| 551 | + expect(localWrapper.vm.shouldFireAsyncConfetti).toBe(false) |
| 552 | + }) |
| 553 | + |
| 554 | + it('does not set isAsyncSigning when history.state has no isAsync flag', () => { |
| 555 | + stateGetter = vi.spyOn(window.history, 'state', 'get').mockReturnValue({} as any) |
| 556 | + const localWrapper = shallowMount(Validation, { |
| 557 | + mocks: { |
| 558 | + $route: { params: { uuid: UUID }, query: {} }, |
| 559 | + $router: { ...mockRouter, replace: vi.fn() }, |
| 560 | + }, |
| 561 | + }) |
| 562 | + expect(localWrapper.vm.isAsyncSigning).toBe(false) |
| 563 | + expect(localWrapper.vm.shouldFireAsyncConfetti).toBe(false) |
| 564 | + }) |
| 565 | + }) |
| 566 | + |
| 567 | + describe('handleValidationSuccess - confetti behavior', () => { |
| 568 | + // FILE_STATUS.SIGNED = 3 |
| 569 | + const SIGNED_STATUS = 3 |
| 570 | + |
| 571 | + it('fires confetti when document is signed and isAfterSigned returns true', () => { |
| 572 | + // Spy on the computed getter to simulate the route-param path |
| 573 | + // (Vue 3 mocked $route.params lacks reactivity in test env — covered separately) |
| 574 | + vi.spyOn(wrapper.vm, 'isAfterSigned', 'get').mockReturnValue(true) |
| 575 | + wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) |
| 576 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 577 | + }) |
| 578 | + |
| 579 | + it('fires confetti when document is signed and shouldFireAsyncConfetti is true', async () => { |
| 580 | + await wrapper.setData({ shouldFireAsyncConfetti: true }) |
| 581 | + wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) |
| 582 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 583 | + }) |
| 584 | + |
| 585 | + it('fires confetti when all files in envelope are signed and shouldFireAsyncConfetti is true', async () => { |
| 586 | + await wrapper.setData({ shouldFireAsyncConfetti: true }) |
| 587 | + wrapper.vm.handleValidationSuccess({ |
| 588 | + status: 0, |
| 589 | + files: [ |
| 590 | + { status: SIGNED_STATUS }, |
| 591 | + { status: SIGNED_STATUS }, |
| 592 | + ], |
| 593 | + signers: [], |
| 594 | + }) |
| 595 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 596 | + }) |
| 597 | + |
| 598 | + it('fires confetti when current signer is signed and shouldFireAsyncConfetti is true', async () => { |
| 599 | + await wrapper.setData({ shouldFireAsyncConfetti: true }) |
| 600 | + // SIGN_REQUEST_STATUS.SIGNED = 2 |
| 601 | + wrapper.vm.handleValidationSuccess({ |
| 602 | + status: 0, |
| 603 | + signers: [{ me: true, status: 2 }], |
| 604 | + }) |
| 605 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 606 | + }) |
| 607 | + |
| 608 | + it('does not fire confetti when document is not signed even if isAfterSigned is true', () => { |
| 609 | + const localWrapper = shallowMount(Validation, { |
| 610 | + mocks: { |
| 611 | + $route: { params: { isAfterSigned: true }, query: {} }, |
| 612 | + $router: mockRouter, |
| 613 | + }, |
| 614 | + }) |
| 615 | + localWrapper.vm.handleValidationSuccess({ status: 1, signers: [] }) |
| 616 | + expect(mockAddConfetti).not.toHaveBeenCalled() |
| 617 | + }) |
| 618 | + |
| 619 | + it('does not fire confetti when document is signed but neither isAfterSigned nor shouldFireAsyncConfetti is true', () => { |
| 620 | + wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) |
| 621 | + expect(mockAddConfetti).not.toHaveBeenCalled() |
| 622 | + }) |
| 623 | + |
| 624 | + it('resets shouldFireAsyncConfetti to false after firing confetti', async () => { |
| 625 | + await wrapper.setData({ shouldFireAsyncConfetti: true }) |
| 626 | + wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) |
| 627 | + expect(wrapper.vm.shouldFireAsyncConfetti).toBe(false) |
| 628 | + }) |
| 629 | + |
| 630 | + it('does not reset shouldFireAsyncConfetti when confetti is not fired', async () => { |
| 631 | + await wrapper.setData({ shouldFireAsyncConfetti: true }) |
| 632 | + // document not signed → confetti won't fire |
| 633 | + wrapper.vm.handleValidationSuccess({ status: 1, signers: [] }) |
| 634 | + expect(wrapper.vm.shouldFireAsyncConfetti).toBe(true) |
| 635 | + }) |
| 636 | + |
| 637 | + it('does not fire confetti when isActiveView is false', async () => { |
| 638 | + await wrapper.setData({ shouldFireAsyncConfetti: true, isActiveView: false }) |
| 639 | + wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) |
| 640 | + expect(mockAddConfetti).not.toHaveBeenCalled() |
| 641 | + }) |
| 642 | + }) |
| 643 | + |
| 644 | + describe('handleSigningComplete method', () => { |
| 645 | + // FILE_STATUS.SIGNED = 3 |
| 646 | + const SIGNED_STATUS = 3 |
| 647 | + // SIGN_REQUEST_STATUS.SIGNED = 2 |
| 648 | + const SIGNER_SIGNED_STATUS = 2 |
| 649 | + |
| 650 | + it('sets isAsyncSigning to false when called', async () => { |
| 651 | + await wrapper.setData({ isAsyncSigning: true }) |
| 652 | + vi.spyOn(wrapper.vm, 'refreshAfterAsyncSigning').mockResolvedValue(undefined) |
| 653 | + wrapper.vm.handleSigningComplete(null) |
| 654 | + expect(wrapper.vm.isAsyncSigning).toBe(false) |
| 655 | + }) |
| 656 | + |
| 657 | + it('sets shouldFireAsyncConfetti to true when called', async () => { |
| 658 | + vi.spyOn(wrapper.vm, 'refreshAfterAsyncSigning').mockResolvedValue(undefined) |
| 659 | + wrapper.vm.handleSigningComplete(null) |
| 660 | + expect(wrapper.vm.shouldFireAsyncConfetti).toBe(true) |
| 661 | + }) |
| 662 | + |
| 663 | + it('does nothing when isActiveView is false', async () => { |
| 664 | + await wrapper.setData({ isAsyncSigning: true, isActiveView: false }) |
| 665 | + wrapper.vm.handleSigningComplete(null) |
| 666 | + expect(wrapper.vm.isAsyncSigning).toBe(true) |
| 667 | + expect(wrapper.vm.shouldFireAsyncConfetti).toBe(false) |
| 668 | + }) |
| 669 | + |
| 670 | + describe('RULE: when a file is returned directly by SigningProgress', () => { |
| 671 | + it('fires confetti when the file has signed status', () => { |
| 672 | + const signedFile = { status: SIGNED_STATUS, signers: [] } |
| 673 | + wrapper.vm.handleSigningComplete(signedFile) |
| 674 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 675 | + }) |
| 676 | + |
| 677 | + it('fires confetti when the current signer is marked as signed', () => { |
| 678 | + const fileWithSignedSigner = { |
| 679 | + status: 1, |
| 680 | + signers: [{ me: true, status: SIGNER_SIGNED_STATUS, signed: '2025-01-01T00:00:00Z' }], |
| 681 | + } |
| 682 | + wrapper.vm.handleSigningComplete(fileWithSignedSigner) |
| 683 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 684 | + }) |
| 685 | + |
| 686 | + it('does not fire confetti when the file is not yet signed and no signer is marked as signed', () => { |
| 687 | + // This is a realistic scenario: SigningProgress emits 'completed' |
| 688 | + // with a file object whose status is still pending/partial |
| 689 | + const unsignedFile = { status: 1, signers: [{ me: true, status: 0 }] } |
| 690 | + wrapper.vm.handleSigningComplete(unsignedFile) |
| 691 | + expect(mockAddConfetti).not.toHaveBeenCalled() |
| 692 | + }) |
| 693 | + }) |
| 694 | + |
| 695 | + describe('RULE: when SigningProgress emits completed without a file (async polling path)', () => { |
| 696 | + it('fires confetti after polling returns a signed document', async () => { |
| 697 | + await wrapper.setData({ uuidToValidate: '550e8400-e29b-41d4-a716-446655440000' }) |
| 698 | + |
| 699 | + // Simulate the validate call returning a signed document via handleValidationSuccess |
| 700 | + vi.spyOn(wrapper.vm, 'validate').mockImplementation(async () => { |
| 701 | + wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) |
| 702 | + }) |
| 703 | + |
| 704 | + wrapper.vm.handleSigningComplete(null) |
| 705 | + |
| 706 | + // Wait for the async polling loop to run |
| 707 | + await new Promise(resolve => setTimeout(resolve, 0)) |
| 708 | + |
| 709 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 710 | + }) |
| 711 | + |
| 712 | + it('fires confetti after polling finds that the current signer is signed', async () => { |
| 713 | + await wrapper.setData({ uuidToValidate: '550e8400-e29b-41d4-a716-446655440000' }) |
| 714 | + |
| 715 | + vi.spyOn(wrapper.vm, 'validate').mockImplementation(async () => { |
| 716 | + wrapper.vm.handleValidationSuccess({ |
| 717 | + status: 1, |
| 718 | + signers: [{ me: true, status: SIGNER_SIGNED_STATUS, signed: '2025-01-01T00:00:00Z' }], |
| 719 | + }) |
| 720 | + }) |
| 721 | + |
| 722 | + wrapper.vm.handleSigningComplete(null) |
| 723 | + await new Promise(resolve => setTimeout(resolve, 0)) |
| 724 | + |
| 725 | + expect(mockAddConfetti).toHaveBeenCalledOnce() |
| 726 | + }) |
| 727 | + }) |
| 728 | + }) |
472 | 729 | }) |
0 commit comments