Skip to content

Commit 569df84

Browse files
test(validation): add js-confetti mock and cover async signing confetti path
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent 3bf7615 commit 569df84

1 file changed

Lines changed: 257 additions & 0 deletions

File tree

src/tests/views/Validation.spec.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55

66
import { describe, expect, it, beforeEach, vi } from 'vitest'
77
import { shallowMount } from '@vue/test-utils'
8+
import axios from '@nextcloud/axios'
9+
import JSConfetti from 'js-confetti'
810
import Validation from '../../views/Validation.vue'
911

12+
// Mock js-confetti
13+
vi.mock('js-confetti', () => ({
14+
default: vi.fn(),
15+
}))
16+
1017
// Mock @nextcloud packages
1118
vi.mock('@nextcloud/axios', () => ({
1219
default: {
@@ -125,8 +132,15 @@ vi.mock('../../utils/fileStatus.js', () => ({
125132

126133
describe('Validation.vue - Business Logic', () => {
127134
let wrapper!: ReturnType<typeof shallowMount>
135+
let mockAddConfetti: ReturnType<typeof vi.fn>
128136

129137
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+
130144
wrapper = shallowMount(Validation, {
131145
mocks: {
132146
$route: mockRoute,
@@ -469,4 +483,247 @@ describe('Validation.vue - Business Logic', () => {
469483
expect(wrapper.vm.EXPIRATION_WARNING_DAYS).toBe(30)
470484
})
471485
})
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+
})
472729
})

0 commit comments

Comments
 (0)