i18n: Upgrade translations from crowdin (61e08dd5). (pass-desktop)
[ProtonMail-WebClient.git] / packages / docs-core / lib / Controller / Document / DocController.spec.ts
blob53ab768d28cecbc3bef6fca8b6d2d3489119d3d7
1 import { ConnectionCloseReason } from '@proton/docs-proto'
2 import type {
3   BroadcastSource,
4   ClientRequiresEditorMethods,
5   DocTrashState,
6   InternalEventBusInterface,
7   RtsMessagePayload,
8 } from '@proton/docs-shared'
9 import { DecryptedMessage, DocumentRole } from '@proton/docs-shared'
10 import type { DriveCompat, NodeMeta } from '@proton/drive-store'
11 import type { LoggerInterface } from '@proton/utils/logs'
12 import { Result } from '../../Domain/Result/Result'
13 import { MAX_DOC_SIZE, MAX_UPDATE_SIZE } from '../../Models/Constants'
14 import type { DecryptedCommit } from '../../Models/DecryptedCommit'
15 import type { WebsocketConnectionEvent } from '../../Realtime/WebsocketEvent/WebsocketConnectionEvent'
16 import type { WebsocketConnectionEventPayloads } from '../../Realtime/WebsocketEvent/WebsocketConnectionEventPayloads'
17 import type { WebsocketServiceInterface } from '../../Services/Websockets/WebsocketServiceInterface'
18 import type { DocumentEntitlements } from '../../Types/DocumentEntitlements'
19 import type { CreateNewDocument } from '../../UseCase/CreateNewDocument'
20 import type { DuplicateDocument } from '../../UseCase/DuplicateDocument'
21 import type { ExportAndDownload } from '../../UseCase/ExportAndDownload'
22 import type { GetDocumentMeta } from '../../UseCase/GetDocumentMeta'
23 import type { LoadCommit } from '../../UseCase/LoadCommit'
24 import type { LoadDocument } from '../../UseCase/LoadDocument'
25 import type { SeedInitialCommit } from '../../UseCase/SeedInitialCommit'
26 import type { SquashDocument } from '../../UseCase/SquashDocument'
27 import { DocController } from './DocController'
28 import { DocControllerEvent } from './DocControllerEvent'
29 import { DocumentMeta } from '../../Models/DocumentMeta'
30 import type { GetNode } from '../../UseCase/GetNode'
32 describe('DocController', () => {
33   let controller: DocController
34   let driveCompat = {
35     getNode: jest.fn(),
36     trashDocument: jest.fn(),
37     restoreDocument: jest.fn(),
38     getShareId: jest.fn(),
39   }
41   beforeEach(async () => {
42     controller = new DocController(
43       {} as NodeMeta,
44       driveCompat as unknown as jest.Mocked<DriveCompat>,
45       {} as jest.Mocked<SquashDocument>,
46       {} as jest.Mocked<SeedInitialCommit>,
47       {
48         executePrivate: jest.fn().mockReturnValue(
49           Result.ok({
50             node: { parentNodeId: 'parent-node-id-123' },
51             meta: { nodeMeta: { linkId: 'link-id-123' } },
52             lastCommitId: '123',
53             entitlements: { keys: {}, role: new DocumentRole('Editor') },
54           }),
55         ),
56       } as unknown as jest.Mocked<LoadDocument>,
57       {
58         execute: jest.fn().mockReturnValue(
59           Result.ok({
60             numberOfUpdates: jest.fn(),
61             squashedRepresentation: jest.fn().mockReturnValue(new Uint8Array(0)),
62             needsSquash: jest.fn().mockReturnValue(false),
63             byteSize: 0,
64           }),
65         ),
66       } as unknown as jest.Mocked<LoadCommit>,
67       {} as jest.Mocked<DuplicateDocument>,
68       {} as jest.Mocked<CreateNewDocument>,
69       {
70         execute: jest.fn().mockReturnValue(
71           Result.ok({
72             latestCommitId: jest.fn().mockReturnValue('123'),
73           }),
74         ),
75       } as unknown as jest.Mocked<GetDocumentMeta>,
76       {} as jest.Mocked<ExportAndDownload>,
77       {
78         execute: jest.fn().mockReturnValue(Result.ok({})),
79       } as unknown as jest.Mocked<GetNode>,
80       {
81         createConnection: jest.fn().mockReturnValue({ connect: jest.fn().mockResolvedValue(true) }),
82         sendDocumentUpdateMessage: jest.fn(),
83         flushPendingUpdates: jest.fn(),
84         reconnectToDocumentWithoutDelay: jest.fn(),
85         closeConnection: jest.fn(),
86       } as unknown as jest.Mocked<WebsocketServiceInterface>,
87       {
88         addEventHandler: jest.fn(),
89         publish: jest.fn(),
90       } as unknown as jest.Mocked<InternalEventBusInterface>,
91       {
92         debug: jest.fn(),
93         info: jest.fn(),
94         warn: jest.fn(),
95         error: console.error,
96       } as unknown as jest.Mocked<LoggerInterface>,
97     )
99     controller.entitlements = {
100       role: {
101         canEdit: () => true,
102       },
103       keys: {},
104     } as unknown as DocumentEntitlements
106     controller.beginInitialSyncTimer = jest.fn()
107     controller.beginInitialConnectionTimer = jest.fn()
109     controller.editorInvoker = {
110       receiveMessage: jest.fn(),
111       showEditor: jest.fn(),
112       performOpeningCeremony: jest.fn(),
113       changeLockedState: jest.fn().mockResolvedValue(false),
114       performClosingCeremony: jest.fn(),
115       getDocumentState: jest.fn(),
116     } as unknown as jest.Mocked<ClientRequiresEditorMethods>
118     await controller.initialize()
119   })
121   it('should queue updates received while editor was not yet ready', async () => {
122     controller.editorInvoker = undefined
124     await controller.handleDocumentUpdatesMessage({} as DecryptedMessage)
126     expect(controller.updatesReceivedWhileEditorInvokerWasNotReady).toHaveLength(1)
127   })
129   it('should replay queued updates as soon as editor is ready, and clear the queue', async () => {
130     controller.editorInvoker = undefined
132     await controller.handleDocumentUpdatesMessage(
133       new DecryptedMessage({
134         content: new Uint8Array(),
135         signature: new Uint8Array(),
136         authorAddress: '123',
137         aad: '123',
138         timestamp: 0,
139       }),
140     )
142     controller.handleDocumentUpdatesMessage = jest.fn()
144     await controller.editorIsReadyToReceiveInvocations({
145       receiveMessage: jest.fn(),
146       showEditor: jest.fn(),
147       performOpeningCeremony: jest.fn(),
148       changeLockedState: jest.fn(),
149     } as unknown as jest.Mocked<ClientRequiresEditorMethods>)
151     expect(controller.handleDocumentUpdatesMessage).toHaveBeenCalled()
152     expect(controller.updatesReceivedWhileEditorInvokerWasNotReady).toHaveLength(0)
153   })
155   describe('editorIsReadyToReceiveInvocations', () => {
156     it('should send initial commit to editor', async () => {
157       controller.sendInitialCommitToEditor = jest.fn()
159       const editorInvoker = controller.editorInvoker!
160       controller.editorInvoker = undefined
161       await controller.editorIsReadyToReceiveInvocations(editorInvoker)
163       expect(controller.sendInitialCommitToEditor).toHaveBeenCalled()
164     })
165   })
167   describe('initialize', () => {
168     it('should set the initial commit', async () => {
169       controller.setInitialCommit = jest.fn()
171       await controller.initialize()
173       expect(controller.setInitialCommit).toHaveBeenCalled()
174     })
175   })
177   describe('setInitialCommit', () => {
178     it('should send the initial commit to the editor', async () => {
179       controller.sendInitialCommitToEditor = jest.fn()
181       await controller.setInitialCommit({
182         needsSquash: jest.fn(),
183         squashedRepresentation: jest.fn().mockReturnValue(new Uint8Array()),
184       } as unknown as jest.Mocked<DecryptedCommit>)
186       expect(controller.sendInitialCommitToEditor).toHaveBeenCalled()
187     })
189     it('should set last commit id property', async () => {
190       await controller.setInitialCommit({
191         needsSquash: jest.fn(),
192         commitId: '456',
193         squashedRepresentation: jest.fn().mockReturnValue(new Uint8Array()),
194       } as unknown as jest.Mocked<DecryptedCommit>)
196       expect(controller.lastCommitIdReceivedFromRtsOrApi).toBe('456')
197     })
198   })
200   describe('showEditorIfAllConnectionsReady', () => {
201     it('should not show editor if docs server connection is not ready', () => {
202       controller.realtimeConnectionReady = true
203       controller.docsServerDataReady = false
204       controller.showEditor = jest.fn()
206       controller.showEditorIfAllConnectionsReady()
208       expect(controller.showEditor).not.toHaveBeenCalled()
209     })
211     it('should not show editor if realtime connection is not ready', () => {
212       controller.realtimeConnectionReady = false
213       controller.docsServerDataReady = true
214       controller.showEditor = jest.fn()
216       controller.showEditorIfAllConnectionsReady()
218       expect(controller.showEditor).not.toHaveBeenCalled()
219     })
221     it('should not show editor if editorInvoker is undefined', () => {
222       controller.editorInvoker = undefined
223       controller.realtimeConnectionReady = true
224       controller.docsServerDataReady = true
225       controller.showEditor = jest.fn()
227       controller.showEditorIfAllConnectionsReady()
229       expect(controller.showEditor).not.toHaveBeenCalled()
230     })
231   })
233   describe('handleDocumentUpdatesMessage', () => {
234     it('should increment size tracker size', async () => {
235       controller.sizeTracker.incrementSize = jest.fn()
237       await controller.handleDocumentUpdatesMessage({
238         byteSize: jest.fn().mockReturnValue(25),
239       } as unknown as DecryptedMessage)
241       expect(controller.sizeTracker.incrementSize).toHaveBeenCalledWith(25)
242     })
243   })
245   describe('handleRealtimeConnectionReady', () => {
246     it('should show editor', () => {
247       controller.didAlreadyReceiveEditorReadyEvent = true
248       controller.docsServerDataReady = true
250       controller.handleRealtimeConnectionReady()
252       expect(controller.editorInvoker!.showEditor).toHaveBeenCalled()
253     })
254   })
256   describe('reloadEditingLockedState', () => {
257     it('should lock if user does not have editing permissions', () => {
258       controller.doesUserHaveEditingPermissions = jest.fn().mockReturnValue(false)
260       controller.reloadEditingLockedState()
262       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
263     })
265     it('should lock if experiencing errored sync', () => {
266       controller.isExperiencingErroredSync = true
268       controller.reloadEditingLockedState()
270       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
271     })
273     it('should lock if websocket status is connecting', () => {
274       controller.websocketStatus = 'connecting'
276       controller.reloadEditingLockedState()
278       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
279     })
281     it('should lock if size constraint reached', () => {
282       controller.isLockedDueToSizeContraint = true
284       controller.reloadEditingLockedState()
286       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
287     })
289     it('should lock if websocket status is disconnected', () => {
290       controller.websocketStatus = 'disconnected'
292       controller.reloadEditingLockedState()
294       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
295     })
297     it('should unlock if all flags are green', () => {
298       controller.doesUserHaveEditingPermissions = jest.fn().mockReturnValue(true)
299       controller.isExperiencingErroredSync = false
300       controller.websocketStatus = 'connected'
302       controller.reloadEditingLockedState()
304       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(false)
305     })
307     it('should lock if participation limit reached and user is not owner', () => {
308       controller.doesUserOwnDocument = jest.fn().mockReturnValue(false)
309       controller.participantTracker.isParticipantLimitReached = jest.fn().mockReturnValue(true)
311       controller.reloadEditingLockedState()
313       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
314     })
316     it('should not lock if participation limit reached and user is owner', () => {
317       controller.doesUserOwnDocument = jest.fn().mockReturnValue(true)
318       controller.participantTracker.isParticipantLimitReached = jest.fn().mockReturnValue(true)
320       controller.reloadEditingLockedState()
322       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
323     })
325     it('should lock if editor has rendering issue', () => {
326       controller.hasEditorRenderingIssue = true
328       controller.reloadEditingLockedState()
330       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
331     })
333     it('should lock if trashedState is trashed', () => {
334       controller.trashState = 'trashed'
336       controller.reloadEditingLockedState()
338       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
339     })
341     it('should lock if trashedState is trashed', () => {
342       controller.trashState = 'trashed'
344       controller.reloadEditingLockedState()
346       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
347     })
349     it('should report metric if locked', () => {
350       controller.hasEditorRenderingIssue = true
351       controller.incrementMetricsReadonlyState = jest.fn()
353       controller.reloadEditingLockedState()
355       expect(controller.incrementMetricsReadonlyState).toHaveBeenCalled()
356     })
357   })
359   describe('websocket lifecycle', () => {
360     it('should lock document when websocket is still connecting', () => {
361       controller.handleWebsocketConnectingEvent()
363       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
364     })
366     it('should begin initial sync timer on connected event', () => {
367       controller.beginInitialSyncTimer = jest.fn()
369       controller.handleWebsocketConnectedEvent()
371       expect(controller.beginInitialSyncTimer).toHaveBeenCalled()
372     })
373   })
375   describe('handleWebsocketDisconnectedEvent', () => {
376     it('should prevent editing when websocket is closed', () => {
377       const payload: WebsocketConnectionEventPayloads[WebsocketConnectionEvent.Disconnected] = {
378         document: {} as NodeMeta,
379         serverReason: ConnectionCloseReason.create({ code: ConnectionCloseReason.CODES.NORMAL_CLOSURE }),
380       }
381       controller.handleWebsocketDisconnectedEvent(payload)
383       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
384     })
386     it('should refetch commit if disconnect reason is stale commit id', () => {
387       const payload: WebsocketConnectionEventPayloads[WebsocketConnectionEvent.Disconnected] = {
388         document: {} as NodeMeta,
389         serverReason: ConnectionCloseReason.create({ code: ConnectionCloseReason.CODES.STALE_COMMIT_ID }),
390       }
391       controller.refetchCommitDueToStaleContents = jest.fn()
393       controller.handleWebsocketDisconnectedEvent(payload)
395       expect(controller.refetchCommitDueToStaleContents).toHaveBeenCalled()
396     })
397   })
399   describe('handleCommitIdOutOfSyncEvent', () => {
400     it('should refetch commit', () => {
401       controller.refetchCommitDueToStaleContents = jest.fn()
403       controller.handleFailedToGetTokenDueToCommitIdOutOfSyncEvent()
405       expect(controller.refetchCommitDueToStaleContents).toHaveBeenCalled()
406     })
407   })
409   describe('refetchCommitDueToStaleContents', () => {
410     it('should post UnableToResolveCommitIdConflict error if unable to resolve', async () => {
411       controller.logger.error = jest.fn()
413       controller._loadCommit.execute = jest.fn().mockReturnValue(Result.fail('Unable to resolve'))
414       controller._getDocumentMeta.execute = jest.fn().mockReturnValue(Result.fail('Unable to resolve'))
416       await controller.refetchCommitDueToStaleContents('rts-disconnect')
418       expect(controller.eventBus.publish).toHaveBeenCalledWith({
419         type: DocControllerEvent.UnableToResolveCommitIdConflict,
420         payload: undefined,
421       })
422     })
424     it('should not reprocess if already processing', async () => {
425       controller._loadCommit.execute = jest.fn().mockReturnValue(
426         Result.ok({
427           numberOfUpdates: jest.fn(),
428           squashedRepresentation: jest.fn().mockReturnValue(new Uint8Array()),
429           needsSquash: jest.fn().mockReturnValue(false),
430         }),
431       )
432       controller._getDocumentMeta.execute = jest.fn().mockReturnValue(Result.ok({}))
434       controller.isRefetchingStaleCommit = true
436       await controller.refetchCommitDueToStaleContents('rts-disconnect')
438       expect(controller._loadCommit.execute).not.toHaveBeenCalled()
439     })
441     it('should re-set the initial commit if commit is successfully resolved', async () => {
442       controller._loadCommit.execute = jest.fn().mockReturnValue(
443         Result.ok({
444           numberOfUpdates: jest.fn().mockReturnValue(0),
445           needsSquash: jest.fn().mockReturnValue(false),
446           squashedRepresentation: jest.fn().mockReturnValue(new Uint8Array()),
447         }),
448       )
450       controller.setInitialCommit = jest.fn()
452       await controller.refetchCommitDueToStaleContents('rts-disconnect')
454       expect(controller.setInitialCommit).toHaveBeenCalled()
455     })
457     it('should reconnect websocket without delay if commit is successfully resolved', async () => {
458       controller._loadCommit.execute = jest.fn().mockReturnValue(
459         Result.ok({
460           numberOfUpdates: jest.fn().mockReturnValue(0),
461           needsSquash: jest.fn().mockReturnValue(false),
462           squashedRepresentation: jest.fn().mockReturnValue(new Uint8Array()),
463         }),
464       )
466       await controller.refetchCommitDueToStaleContents('rts-disconnect')
468       expect(controller.websocketService.reconnectToDocumentWithoutDelay).toHaveBeenCalled()
469     })
470   })
472   describe('handleWebsocketConnectedEvent', () => {
473     it('should allow editing', () => {
474       controller.handleWebsocketConnectedEvent()
476       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(false)
477     })
478   })
480   describe('handleWebsocketFailedToConnectEvent', () => {
481     it('should block editing', () => {
482       controller.handleWebsocketFailedToConnectEvent()
484       expect(controller.editorInvoker!.changeLockedState).toHaveBeenCalledWith(true)
485     })
487     it('should set state to disconnected', () => {
488       controller.handleWebsocketFailedToConnectEvent()
490       expect(controller.websocketStatus).toBe('disconnected')
491     })
492   })
494   describe('handleWebsocketAckStatusChangeEvent', () => {
495     it('should reload editing locked state', () => {
496       controller.reloadEditingLockedState = jest.fn()
498       controller.handleWebsocketAckStatusChangeEvent({
499         ledger: { hasErroredMessages: jest.fn() },
500       } as unknown as WebsocketConnectionEventPayloads[WebsocketConnectionEvent.AckStatusChange])
502       expect(controller.reloadEditingLockedState).toHaveBeenCalled()
503     })
504   })
506   describe('editorRequestsPropagationOfUpdate', () => {
507     it('should propagate update', () => {
508       void controller.editorRequestsPropagationOfUpdate(
509         {
510           type: {
511             wrapper: 'du',
512           },
513           content: {
514             byteLength: 123,
515           },
516         } as RtsMessagePayload,
517         'mock' as BroadcastSource,
518       )
520       expect(controller.websocketService.sendDocumentUpdateMessage).toHaveBeenCalled()
521     })
523     it('should increment size tracker size', () => {
524       controller.sizeTracker.incrementSize = jest.fn()
526       void controller.editorRequestsPropagationOfUpdate(
527         {
528           type: {
529             wrapper: 'du',
530           },
531           content: {
532             byteLength: 123,
533           },
534         } as RtsMessagePayload,
535         'mock' as BroadcastSource,
536       )
538       expect(controller.sizeTracker.incrementSize).toHaveBeenCalledWith(123)
539     })
541     it('should refuse propagation of update if the update is larger than the max update size', () => {
542       controller.handleAttemptingToBroadcastUpdateThatIsTooLarge = jest.fn()
544       void controller.editorRequestsPropagationOfUpdate(
545         {
546           type: {
547             wrapper: 'du',
548           },
549           content: {
550             byteLength: MAX_UPDATE_SIZE + 1,
551           },
552         } as RtsMessagePayload,
553         'mock' as BroadcastSource,
554       )
556       expect(controller.handleAttemptingToBroadcastUpdateThatIsTooLarge).toHaveBeenCalled()
557       expect(controller.websocketService.sendDocumentUpdateMessage).not.toHaveBeenCalled()
558     })
560     it('should refuse propagation of update if the update would exceed total document size', () => {
561       controller.sizeTracker.resetWithSize(MAX_DOC_SIZE - 1)
563       controller.handleAttemptingToBroadcastUpdateThatIsTooLarge = jest.fn()
565       void controller.editorRequestsPropagationOfUpdate(
566         {
567           type: {
568             wrapper: 'du',
569           },
570           content: {
571             byteLength: 2,
572           },
573         } as RtsMessagePayload,
574         'mock' as BroadcastSource,
575       )
577       expect(controller.handleAttemptingToBroadcastUpdateThatIsTooLarge).toHaveBeenCalled()
578       expect(controller.websocketService.sendDocumentUpdateMessage).not.toHaveBeenCalled()
579     })
581     it('should handle special conversion flow', () => {
582       controller.handleEditorProvidingInitialConversionContent = jest.fn()
584       void controller.editorRequestsPropagationOfUpdate(
585         {
586           type: {
587             wrapper: 'conversion',
588           },
589           content: {
590             byteLength: 123,
591           },
592         } as RtsMessagePayload,
593         'mock' as BroadcastSource,
594       )
596       expect(controller.handleEditorProvidingInitialConversionContent).toHaveBeenCalled()
597     })
599     it('should not seed initial commit if update is not conversion', () => {
600       controller.handleEditorProvidingInitialConversionContent = jest.fn()
602       void controller.editorRequestsPropagationOfUpdate(
603         {
604           type: {
605             wrapper: 'du',
606           },
607           content: {
608             byteLength: 123,
609           },
610         } as RtsMessagePayload,
611         'mock' as BroadcastSource,
612       )
614       expect(controller.handleEditorProvidingInitialConversionContent).not.toHaveBeenCalled()
615     })
616   })
618   describe('createInitialCommit', () => {
619     it('should set lastCommitIdReceivedFromRtsOrApi', async () => {
620       controller._createInitialCommit.execute = jest.fn().mockReturnValue(Result.ok({ commitId: '123' }))
622       await controller.createInitialCommit()
624       expect(controller.lastCommitIdReceivedFromRtsOrApi).toBe('123')
625     })
626   })
628   describe('handleAttemptingToBroadcastUpdateThatIsTooLarge', () => {
629     it('should lock editing', () => {
630       controller.logger.error = jest.fn()
632       controller.reloadEditingLockedState = jest.fn()
634       controller.handleAttemptingToBroadcastUpdateThatIsTooLarge()
636       expect(controller.isLockedDueToSizeContraint).toBe(true)
638       expect(controller.reloadEditingLockedState).toHaveBeenCalled()
639     })
640   })
642   describe('handleEditorProvidingInitialConversionContent', () => {
643     beforeEach(() => {
644       controller.createInitialCommit = jest.fn().mockReturnValue(Result.ok({ commitId: '123' }))
645     })
647     it('should abort existing connection attempt', async () => {
648       controller.websocketService.closeConnection = jest.fn()
650       const promise = controller.handleEditorProvidingInitialConversionContent(new Uint8Array())
652       expect(controller.abortWebsocketConnectionAttempt).toEqual(true)
654       await promise
656       expect(controller.abortWebsocketConnectionAttempt).toEqual(false)
657       expect(controller.websocketService.closeConnection).toHaveBeenCalled()
658     })
660     it('should create initial commit', async () => {
661       await controller.handleEditorProvidingInitialConversionContent(new Uint8Array())
663       expect(controller.createInitialCommit).toHaveBeenCalled()
664     })
666     it('should reconnect to document without delay', async () => {
667       controller.websocketService.reconnectToDocumentWithoutDelay = jest.fn()
669       await controller.handleEditorProvidingInitialConversionContent(new Uint8Array())
671       expect(controller.websocketService.reconnectToDocumentWithoutDelay).toHaveBeenCalled()
672     })
673   })
675   describe('editorIsRequestingToLockAfterRenderingIssue', () => {
676     it('should set hasEditorRenderingIssue', () => {
677       controller.editorIsRequestingToLockAfterRenderingIssue()
679       expect(controller.hasEditorRenderingIssue).toBe(true)
680     })
682     it('should reload editing locked state', () => {
683       controller.reloadEditingLockedState = jest.fn()
685       controller.editorIsRequestingToLockAfterRenderingIssue()
687       expect(controller.reloadEditingLockedState).toHaveBeenCalled()
688     })
689   })
691   describe('trashDocument', () => {
692     let publishDocumentTrashStateUpdated: jest.SpyInstance
694     beforeEach(() => {
695       controller.docMeta = new DocumentMeta({ linkId: 'abc', volumeId: 'def' }, ['ghi'], 123, 456, 'jkl')
697       controller._getNode.execute = jest
698         .fn()
699         .mockResolvedValue(Result.ok({ node: { parentNodeId: '123', trashed: true } }))
701       publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
702     })
704     it('trashState should be trashing initially', () => {
705       const promise = controller.trashDocument()
706       expect(controller.getTrashState()).toBe('trashing')
707       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(1)
708       return promise
709     })
711     it('trashState should be trashed when complete', async () => {
712       const publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
713       await controller.trashDocument()
714       expect(controller.getTrashState()).toBe('trashed')
715       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(2)
716     })
718     it('should refreshNodeAndDocMeta', async () => {
719       const refreshNodeAndDocMeta = jest.spyOn(controller, 'refreshNodeAndDocMeta')
721       await controller.trashDocument()
722       expect(refreshNodeAndDocMeta).toHaveBeenCalled()
723     })
725     it('should reload editor locked state', async () => {
726       const reloadEditingLockedState = jest.spyOn(controller, 'reloadEditingLockedState')
728       await controller.trashDocument()
729       expect(reloadEditingLockedState).toHaveBeenCalled()
730     })
732     it('should set didTrashDocInCurrentSession to true', async () => {
733       expect(controller.didTrashDocInCurrentSession).toBe(false)
735       await controller.trashDocument()
737       expect(controller.didTrashDocInCurrentSession).toBe(true)
738     })
739   })
741   describe('restoreDocument', () => {
742     let publishDocumentTrashStateUpdated: jest.SpyInstance
744     beforeEach(() => {
745       controller.docMeta = new DocumentMeta({ linkId: 'abc', volumeId: 'def' }, ['ghi'], 123, 456, 'jkl')
747       controller._getNode.execute = jest
748         .fn()
749         .mockResolvedValue(Result.ok({ node: { parentNodeId: '123', trashed: true } }))
751       publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
752     })
754     it('trashState should be restoring initially', () => {
755       controller.trashState = 'trashed'
756       const promise = controller.restoreDocument()
757       expect(controller.getTrashState()).toBe('restoring')
758       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(1)
759       return promise
760     })
762     it('trashState should be restored when complete', async () => {
763       controller.trashState = 'trashed'
764       const publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
765       controller._getNode.execute = jest
766         .fn()
767         .mockResolvedValue(Result.ok({ node: { parentNodeId: '123', trashed: false } }))
768       await controller.restoreDocument()
769       expect(controller.getTrashState()).toBe('not_trashed')
770       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(2)
771     })
773     it('should refreshNodeAndDocMeta', async () => {
774       const refreshNodeAndDocMeta = jest.spyOn(controller, 'refreshNodeAndDocMeta')
776       await controller.restoreDocument()
777       expect(refreshNodeAndDocMeta).toHaveBeenCalled()
778     })
780     it('should reload editor locked state', async () => {
781       const reloadEditingLockedState = jest.spyOn(controller, 'reloadEditingLockedState')
783       await controller.restoreDocument()
784       expect(reloadEditingLockedState).toHaveBeenCalled()
785     })
787     it('should reconnect socket', async () => {
788       controller.trashState = 'trashed'
789       const mock = (controller.websocketService.reconnectToDocumentWithoutDelay = jest.fn())
791       await controller.restoreDocument()
793       expect(mock).toHaveBeenCalled()
794     })
795   })
797   describe.each(['trashed', 'not_trashed', 'restoring', 'trashing'])('setTrashState', (state) => {
798     it(`will set the trashState ${state}`, () => {
799       controller.setTrashState(state as DocTrashState)
800       expect(controller.getTrashState()).toBe(state)
801     })
803     it(`will publish that the state has updated ${state}`, () => {
804       const publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
806       controller.setTrashState(state as DocTrashState)
807       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(1)
808     })
809   })
811   describe('refreshNodeAndDocMeta', () => {
812     it('trashed state will be not_trashed if DecryptedNode trashed property is null', async () => {
813       controller.docMeta = new DocumentMeta({ linkId: 'abc', volumeId: 'def' }, ['ghi'], 123, 456, 'jkl')
815       controller._getNode.execute = jest
816         .fn()
817         .mockResolvedValueOnce(Result.ok({ node: { parentNodeId: 123, trashed: null } }))
818       const publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
820       await controller.refreshNodeAndDocMeta({ imposeTrashState: undefined })
821       expect(controller.getTrashState()).toBe('not_trashed')
822       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(1)
823     })
825     it('trashed state will be not_trashed if DecryptedNode trashed property is omitted', async () => {
826       controller.docMeta = new DocumentMeta({ linkId: 'abc', volumeId: 'def' }, ['ghi'], 123, 456, 'jkl')
828       controller._getNode.execute = jest.fn().mockResolvedValueOnce(Result.ok({ node: { parentNodeId: 123 } }))
829       const publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
831       await controller.refreshNodeAndDocMeta({ imposeTrashState: undefined })
832       expect(controller.getTrashState()).toBe('not_trashed')
833       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(1)
834     })
836     it('trashed state will be trashed if DecryptedNode trashed property is populated', async () => {
837       controller.docMeta = new DocumentMeta({ linkId: 'abc', volumeId: 'def' }, ['ghi'], 123, 456, 'jkl')
839       controller._getNode.execute = jest
840         .fn()
841         .mockResolvedValueOnce(Result.ok({ node: { parentNodeId: 123, trashed: 123 } }))
842       const publishDocumentTrashStateUpdated = jest.spyOn(controller, 'publishDocumentTrashStateUpdated')
844       await controller.refreshNodeAndDocMeta({ imposeTrashState: undefined })
845       expect(controller.getTrashState()).toBe('trashed')
846       expect(publishDocumentTrashStateUpdated).toHaveBeenCalledTimes(1)
847     })
848   })