Skip to content

Commit c377912

Browse files
authored
Merge pull request #7014 from LibreSign/backport/7012/stable33
[stable33] fix: confetti vue router 5 params
2 parents 337cda5 + f302b47 commit c377912

5 files changed

Lines changed: 276 additions & 40 deletions

File tree

src/components/RightSidebar/SignTab.vue

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,15 @@ export default {
7272
onSigned(data) {
7373
this.$router.push({
7474
name: this.$route.path.startsWith('/p/') ? 'ValidationFileExternal' : 'ValidationFile',
75-
params: {
76-
uuid: data.signRequestUuid,
77-
isAfterSigned: true,
78-
},
75+
params: { uuid: data.signRequestUuid },
76+
state: { isAfterSigned: true },
7977
})
8078
},
8179
onSigningStarted(payload) {
8280
this.$router.push({
8381
name: this.$route.path.startsWith('/p/') ? 'ValidationFileExternal' : 'ValidationFile',
84-
params: {
85-
uuid: payload.signRequestUuid,
86-
isAfterSigned: false,
87-
isAsync: true,
88-
},
82+
params: { uuid: payload.signRequestUuid },
83+
state: { isAfterSigned: false, isAsync: true },
8984
})
9085
},
9186
},

src/tests/components/RightSidebar/SignTab.spec.ts

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,8 @@ describe('SignTab', () => {
200200

201201
expect(mockRouter.push).toHaveBeenCalledWith({
202202
name: 'ValidationFile',
203-
params: {
204-
uuid: 'test-uuid',
205-
isAfterSigned: true,
206-
},
203+
params: { uuid: 'test-uuid' },
204+
state: { isAfterSigned: true },
207205
})
208206
})
209207

@@ -215,10 +213,8 @@ describe('SignTab', () => {
215213

216214
expect(mockRouter.push).toHaveBeenCalledWith({
217215
name: 'ValidationFileExternal',
218-
params: {
219-
uuid: 'test-uuid',
220-
isAfterSigned: true,
221-
},
216+
params: { uuid: 'test-uuid' },
217+
state: { isAfterSigned: true },
222218
})
223219
})
224220
})
@@ -232,11 +228,8 @@ describe('SignTab', () => {
232228

233229
expect(mockRouter.push).toHaveBeenCalledWith({
234230
name: 'ValidationFile',
235-
params: {
236-
uuid: 'test-uuid',
237-
isAfterSigned: false,
238-
isAsync: true,
239-
},
231+
params: { uuid: 'test-uuid' },
232+
state: { isAfterSigned: false, isAsync: true },
240233
})
241234
})
242235

@@ -248,11 +241,8 @@ describe('SignTab', () => {
248241

249242
expect(mockRouter.push).toHaveBeenCalledWith({
250243
name: 'ValidationFileExternal',
251-
params: {
252-
uuid: 'test-uuid',
253-
isAfterSigned: false,
254-
isAsync: true,
255-
},
244+
params: { uuid: 'test-uuid' },
245+
state: { isAfterSigned: false, isAsync: true },
256246
})
257247
})
258248
})
@@ -268,11 +258,8 @@ describe('SignTab', () => {
268258
// onSigningStarted should be called in mounted hook and push to router
269259
expect(mockRouter.push).toHaveBeenCalledWith({
270260
name: 'ValidationFile',
271-
params: {
272-
uuid: 'progress-uuid',
273-
isAfterSigned: false,
274-
isAsync: true,
275-
},
261+
params: { uuid: 'progress-uuid' },
262+
state: { isAfterSigned: false, isAsync: true },
276263
})
277264
})
278265

src/tests/views/Validation.spec.ts

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { describe, expect, it, beforeEach, vi } from 'vitest'
6+
import { afterEach, 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
})

src/views/SignPDF/SignPDF.vue

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -349,11 +349,8 @@ export default {
349349
if (targetUuid) {
350350
this.$router.push({
351351
name: targetRoute,
352-
params: {
353-
uuid: targetUuid,
354-
isAfterSigned: false,
355-
isAsync: true,
356-
},
352+
params: { uuid: targetUuid },
353+
state: { isAfterSigned: false, isAsync: true },
357354
})
358355
return true
359356
}

0 commit comments

Comments
 (0)