import { DatePipe } from '@angular/common' import { HttpHeaders, HttpResponse, provideHttpClient, withInterceptorsFromDi, } from '@angular/common/http' import { HttpTestingController, provideHttpClientTesting, } from '@angular/common/http/testing' import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick, } from '@angular/core/testing' import { By } from '@angular/platform-browser' import { ActivatedRoute, Router, RouterModule, convertToParamMap, } from '@angular/router' import { NgbDateStruct, NgbModal, NgbModalRef, } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { DeviceDetectorService } from 'ngx-device-detector' import { Subject, of, throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' import { Correspondent } from 'src/app/data/correspondent' import { CustomFieldDataType } from 'src/app/data/custom-field' import { DataType } from 'src/app/data/datatype' import { Document } from 'src/app/data/document' import { DocumentType } from 'src/app/data/document-type' import { FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_FULLTEXT_MORELIKE, FILTER_HAS_TAGS_ALL, FILTER_STORAGE_PATH, } from 'src/app/data/filter-rule-type' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { ComponentRouterService } from 'src/app/services/component-router.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { PermissionsService } from 'src/app/services/permissions.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentService } from 'src/app/services/rest/document.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { TagService } from 'src/app/services/rest/tag.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' import { UploadState, WebsocketStatusService, } from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { PdfZoomLevel, PdfZoomScale, } from '../common/pdf-viewer/pdf-viewer.types' import { DocumentDetailComponent } from './document-detail.component' const doc: Document = { id: 3, title: 'Doc 3', correspondent: 11, document_type: 21, storage_path: 31, tags: [41, 42, 43], content: 'text content', added: new Date('May 4, 2014 03:24:00'), created: new Date('May 4, 2014 03:24:00'), modified: new Date('May 4, 2014 03:24:00'), archive_serial_number: null, original_file_name: 'file.pdf', owner: null, user_can_change: true, notes: [ { created: new Date(), note: 'note 1', user: { id: 1, username: 'user1' }, }, { created: new Date(), note: 'note 2', user: { id: 2, username: 'user2' }, }, ], custom_fields: [ { field: 0, document: 3, created: new Date(), value: 'custom foo bar', }, ], } const customFields = [ { id: 0, name: 'Field 1', data_type: CustomFieldDataType.String, created: new Date(), }, { id: 1, name: 'Custom Field 2', data_type: CustomFieldDataType.Integer, created: new Date(), }, ] function createFileInput(file?: File) { const input = document.createElement('input') input.type = 'file' const files = file ? ({ 0: file, length: 1, item: () => file, } as unknown as FileList) : ({ length: 0, item: () => null, } as unknown as FileList) Object.defineProperty(input, 'files', { value: files }) input.value = '' return input } describe('DocumentDetailComponent', () => { let component: DocumentDetailComponent let fixture: ComponentFixture let router: Router let activatedRoute: ActivatedRoute let documentService: DocumentService let openDocumentsService: OpenDocumentsService let modalService: NgbModal let toastService: ToastService let documentListViewService: DocumentListViewService let settingsService: SettingsService let customFieldsService: CustomFieldsService let deviceDetectorService: DeviceDetectorService let httpTestingController: HttpTestingController let componentRouterService: ComponentRouterService let websocketStatusService: WebsocketStatusService let currentUserCan = true let currentUserHasObjectPermissions = true let currentUserOwnsObject = true beforeEach(async () => { TestBed.configureTestingModule({ imports: [ DocumentDetailComponent, RouterModule.forRoot(routes), NgxBootstrapIconsModule.pick(allIcons), ], providers: [ DocumentTitlePipe, { provide: TagService, useValue: { getCachedMany: (ids: number[]) => of( ids.map((id) => ({ id, name: `Tag${id}`, is_inbox_tag: true, color: '#ff0000', text_color: '#000000', })) ), listAll: () => of({ count: 3, all: [41, 42, 43], results: [ { id: 41, name: 'Tag41', is_inbox_tag: true, color: '#ff0000', text_color: '#000000', }, { id: 42, name: 'Tag42', is_inbox_tag: true, color: '#ff0000', text_color: '#000000', }, { id: 43, name: 'Tag43', is_inbox_tag: true, color: '#ff0000', text_color: '#000000', }, ], }), }, }, { provide: CorrespondentService, useValue: { listAll: () => of({ results: [ { id: 11, name: 'Correspondent11', }, ], }), }, }, { provide: DocumentTypeService, useValue: { listAll: () => of({ results: [ { id: 21, name: 'DocumentType21', }, ], }), }, }, { provide: StoragePathService, useValue: { listAll: () => of({ results: [ { id: 31, name: 'StoragePath31', }, ], }), }, }, { provide: UserService, useValue: { listAll: () => of({ results: [ { id: 1, username: 'user1', }, { id: 2, username: 'user2', }, ], }), }, }, CustomFieldsService, { provide: PermissionsService, useValue: { currentUserCan: () => currentUserCan, currentUserHasObjectPermissions: () => currentUserHasObjectPermissions, currentUserOwnsObject: () => currentUserOwnsObject, }, }, PermissionsGuard, CustomDatePipe, DatePipe, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ], }).compileComponents() router = TestBed.inject(Router) activatedRoute = TestBed.inject(ActivatedRoute) openDocumentsService = TestBed.inject(OpenDocumentsService) documentService = TestBed.inject(DocumentService) modalService = TestBed.inject(NgbModal) toastService = TestBed.inject(ToastService) documentListViewService = TestBed.inject(DocumentListViewService) settingsService = TestBed.inject(SettingsService) settingsService.currentUser = { id: 1 } customFieldsService = TestBed.inject(CustomFieldsService) deviceDetectorService = TestBed.inject(DeviceDetectorService) fixture = TestBed.createComponent(DocumentDetailComponent) httpTestingController = TestBed.inject(HttpTestingController) componentRouterService = TestBed.inject(ComponentRouterService) websocketStatusService = TestBed.inject(WebsocketStatusService) component = fixture.componentInstance }) function initNormally() { jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) jest .spyOn(documentService, 'get') .mockReturnValueOnce(of(Object.assign({}, doc))) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null) jest .spyOn(openDocumentsService, 'openDocument') .mockReturnValueOnce(of(true)) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ count: customFields.length, all: customFields.map((f) => f.id), results: customFields, }) ) fixture.detectChanges() } it('should load four tabs via url params', () => { jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'notes' }))) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null) jest .spyOn(openDocumentsService, 'openDocument') .mockReturnValueOnce(of(true)) fixture.detectChanges() expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes) }) it('should change url on tab switch', () => { initNormally() const navigateSpy = jest.spyOn(router, 'navigate') component.nav.select(component.DocumentDetailNavIDs.Notes) component.nav.navChange.next({ activeId: 1, nextId: component.DocumentDetailNavIDs.Notes, preventDefault: () => {}, }) fixture.detectChanges() expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'notes']) }) it('should forward id without section to details', () => { const navigateSpy = jest.spyOn(router, 'navigate') jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3 }))) fixture.detectChanges() expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'details'], { replaceUrl: true, }) }) it('should update title after debounce', fakeAsync(() => { initNormally() component.titleInput.value = 'Foo Bar' component.titleSubject.next('Foo Bar') tick(1000) expect(component.documentForm.get('title').value).toEqual('Foo Bar') discardPeriodicTasks() })) it('should update title before doc change if was not updated via debounce', fakeAsync(() => { initNormally() component.titleInput.value = 'Foo Bar' component.titleInput.inputField.nativeElement.dispatchEvent( new Event('change') ) tick(1000) expect(component.documentForm.get('title').value).toEqual('Foo Bar') })) it('should load non-open document via param', () => { initNormally() expect(component.document).toEqual(doc) }) it('should redirect to root when opening a version document id', () => { const navigateSpy = jest.spyOn(router, 'navigate') jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 10, section: 'details' }))) jest .spyOn(documentService, 'get') .mockReturnValueOnce(throwError(() => ({ status: 404 }) as any)) const getRootSpy = jest .spyOn(documentService, 'getRootId') .mockReturnValue(of({ root_id: 3 })) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null) jest .spyOn(openDocumentsService, 'openDocument') .mockReturnValueOnce(of(true)) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ count: customFields.length, all: customFields.map((f) => f.id), results: customFields, }) ) fixture.detectChanges() httpTestingController.expectOne(component.previewUrl).flush('preview') expect(getRootSpy).toHaveBeenCalledWith(10) expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'details'], { replaceUrl: true, }) }) it('should navigate to 404 when root lookup fails', () => { const navigateSpy = jest.spyOn(router, 'navigate') jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 10, section: 'details' }))) jest .spyOn(documentService, 'get') .mockReturnValueOnce(throwError(() => ({ status: 404 }) as any)) jest .spyOn(documentService, 'getRootId') .mockReturnValue(throwError(() => new Error('boom'))) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null) jest .spyOn(openDocumentsService, 'openDocument') .mockReturnValueOnce(of(true)) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ count: customFields.length, all: customFields.map((f) => f.id), results: customFields, }) ) fixture.detectChanges() httpTestingController.expectOne(component.previewUrl).flush('preview') expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true }) }) it('should not render a delete button for the root/original version', () => { const docWithVersions = { ...doc, versions: [ { id: doc.id, added: new Date('2024-01-01T00:00:00Z'), version_label: 'Original', checksum: 'aaaa', is_root: true, }, { id: 10, added: new Date('2024-01-02T00:00:00Z'), version_label: 'Edited', checksum: 'bbbb', is_root: false, }, ], } as Document jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) jest.spyOn(documentService, 'get').mockReturnValueOnce(of(docWithVersions)) jest .spyOn(documentService, 'getMetadata') .mockReturnValue(of({ has_archive_version: true } as any)) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null) jest .spyOn(openDocumentsService, 'openDocument') .mockReturnValueOnce(of(true)) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ count: customFields.length, all: customFields.map((f) => f.id), results: customFields, }) ) fixture.detectChanges() httpTestingController.expectOne(component.previewUrl).flush('preview') fixture.detectChanges() const deleteButtons = fixture.debugElement.queryAll( By.css('pngx-confirm-button') ) expect(deleteButtons.length).toEqual(1) }) it('should fall back to details tab when duplicates tab is active but no duplicates', () => { initNormally() component.activeNavID = component.DocumentDetailNavIDs.Duplicates const noDupDoc = { ...doc, duplicate_documents: [] } component.updateComponent(noDupDoc) expect(component.activeNavID).toEqual( component.DocumentDetailNavIDs.Details ) }) it('should load already-opened document via param', () => { initNormally() jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc)) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ count: customFields.length, all: customFields.map((f) => f.id), results: customFields, }) ) fixture.detectChanges() // calls ngOnInit expect(component.document).toEqual(doc) }) it('should update cached open document duplicates when reloading an open doc', () => { const openDoc = { ...doc, duplicate_documents: [{ id: 1, title: 'Old' }] } const updatedDuplicates = [ { id: 2, title: 'Newer duplicate', deleted_at: null }, ] jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) jest.spyOn(documentService, 'get').mockReturnValue( of({ ...doc, modified: new Date('2024-01-02T00:00:00Z'), duplicate_documents: updatedDuplicates, }) ) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) const saveSpy = jest.spyOn(openDocumentsService, 'save') jest.spyOn(openDocumentsService, 'openDocument').mockReturnValue(of(true)) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ count: customFields.length, all: customFields.map((f) => f.id), results: customFields, }) ) fixture.detectChanges() expect(openDoc.duplicate_documents).toEqual(updatedDuplicates) expect(saveSpy).toHaveBeenCalled() }) it('should disable form if user cannot edit', () => { currentUserHasObjectPermissions = false initNormally() expect(component.documentForm.disabled).toBeTruthy() }) it('should not attempt to retrieve objects if user does not have permissions', () => { currentUserCan = false initNormally() expect(component.correspondents).toBeUndefined() expect(component.documentTypes).toBeUndefined() expect(component.storagePaths).toBeUndefined() expect(component.users).toBeUndefined() httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`) httpTestingController.expectNone( `${environment.apiBaseUrl}documents/correspondents/` ) httpTestingController.expectNone( `${environment.apiBaseUrl}documents/document_types/` ) httpTestingController.expectNone( `${environment.apiBaseUrl}documents/storage_paths/` ) currentUserCan = true }) it('should support creating tag, remove from suggestions', () => { initNormally() component.suggestions = { suggested_tags: ['Tag1', 'NewTag12'], } let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') component.createTag('NewTag12') expect(modalSpy).toHaveBeenCalled() openModal.componentInstance.succeeded.next({ id: 12, name: 'NewTag12', is_inbox_tag: true, color: '#ff0000', text_color: '#000000', }) expect(component.tagsInput.value).toContain(12) expect(component.suggestions.suggested_tags).not.toContain('NewTag12') }) it('should support creating document type, remove from suggestions', () => { initNormally() component.suggestions = { suggested_document_types: ['DocumentType1', 'NewDocType2'], } let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') component.createDocumentType('NewDocType2') expect(modalSpy).toHaveBeenCalled() openModal.componentInstance.succeeded.next({ id: 12, name: 'NewDocType12' }) expect(component.documentForm.get('document_type').value).toEqual(12) expect(component.suggestions.suggested_document_types).not.toContain( 'NewDocType2' ) }) it('should support creating correspondent, remove from suggestions', () => { initNormally() component.suggestions = { suggested_correspondents: ['Correspondent1', 'NewCorrrespondent12'], } let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') component.createCorrespondent('NewCorrrespondent12') expect(modalSpy).toHaveBeenCalled() openModal.componentInstance.succeeded.next({ id: 12, name: 'NewCorrrespondent12', }) expect(component.documentForm.get('correspondent').value).toEqual(12) expect(component.suggestions.suggested_correspondents).not.toContain( 'NewCorrrespondent12' ) }) it('should support creating storage path', () => { initNormally() let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') component.createStoragePath('NewStoragePath12') expect(modalSpy).toHaveBeenCalled() openModal.componentInstance.succeeded.next({ id: 12, name: 'NewStoragePath12', }) expect(component.documentForm.get('storage_path').value).toEqual(12) }) it('should allow dischard changes', () => { initNormally() component.title = 'Foo Bar' fixture.detectChanges() jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc)) component.discard() fixture.detectChanges() expect(component.title).toEqual(doc.title) expect(openDocumentsService.hasDirty()).toBeFalsy() // this time with error, mostly for coverage component.title = 'Foo Bar' fixture.detectChanges() const navigateSpy = jest.spyOn(router, 'navigate') jest .spyOn(documentService, 'get') .mockReturnValueOnce(throwError(() => new Error('unable to discard'))) component.discard() fixture.detectChanges() expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true }) }) it('discard should request the currently selected version', () => { initNormally() const getSpy = jest.spyOn(documentService, 'get') getSpy.mockClear() getSpy.mockReturnValueOnce(of(doc)) component.selectedVersionId = 10 component.discard() expect(getSpy).toHaveBeenCalledWith(component.documentId, 10) }) it('should 404 on invalid id', () => { const navigateSpy = jest.spyOn(router, 'navigate') jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 999, section: 'details' }))) jest.spyOn(documentService, 'get').mockReturnValueOnce(of(null)) fixture.detectChanges() expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true }) }) it('should navigate to 404 if error on load', () => { jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) const navigateSpy = jest.spyOn(router, 'navigate') jest .spyOn(documentService, 'get') .mockReturnValue(throwError(() => new Error('not found'))) fixture.detectChanges() expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true }) }) it('should support save, close and show success toast', () => { initNormally() component.title = 'Foo Bar' const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') const toastSpy = jest.spyOn(toastService, 'showInfo') patchSpy.mockImplementation((o) => of(doc)) component.save(true) expect(patchSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith( 'Document "Doc 3" saved successfully.' ) }) it('should support save without close and show success toast', () => { initNormally() component.title = 'Foo Bar' const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') const toastSpy = jest.spyOn(toastService, 'showInfo') patchSpy.mockImplementation((o) => of(doc)) component.save() expect(patchSpy).toHaveBeenCalled() expect(closeSpy).not.toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith( 'Document "Doc 3" saved successfully.' ) }) it('save should target currently selected version', () => { initNormally() component.selectedVersionId = 10 const patchSpy = jest.spyOn(documentService, 'patch') patchSpy.mockReturnValue(of(doc)) component.save() expect(patchSpy).toHaveBeenCalled() expect(patchSpy.mock.calls[0][1]).toEqual(10) }) it('should show toast error on save if error occurs', () => { currentUserHasObjectPermissions = true initNormally() component.title = 'Foo Bar' const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') const toastSpy = jest.spyOn(toastService, 'showError') const error = new Error('failed to save') patchSpy.mockImplementation(() => throwError(() => error)) component.save() expect(patchSpy).toHaveBeenCalled() expect(closeSpy).not.toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith( 'Error saving document "Doc 3"', error ) }) it('should show error toast on save but close if user can no longer edit', () => { currentUserHasObjectPermissions = false initNormally() component.title = 'Foo Bar' const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') const toastSpy = jest.spyOn(toastService, 'showInfo') patchSpy.mockImplementation(() => throwError(() => new Error('failed to save')) ) component.save(true) expect(patchSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith( 'Document "Doc 3" saved successfully.' ) }) it('should allow save and next', () => { initNormally() const nextDocId = 100 component.title = 'Foo Bar' const patchSpy = jest.spyOn(documentService, 'patch') patchSpy.mockReturnValue(of(doc)) const nextSpy = jest.spyOn(documentListViewService, 'getNext') nextSpy.mockReturnValue(of(nextDocId)) const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') closeSpy.mockReturnValue(of(true)) const navigateSpy = jest.spyOn(router, 'navigate') component.saveEditNext() expect(patchSpy).toHaveBeenCalled() expect(navigateSpy).toHaveBeenCalledWith(['documents', nextDocId]) expect }) it('should show toast error on save & next if error occurs', () => { currentUserHasObjectPermissions = true initNormally() component.title = 'Foo Bar' const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') const toastSpy = jest.spyOn(toastService, 'showError') const error = new Error('failed to save') patchSpy.mockImplementation(() => throwError(() => error)) component.saveEditNext() expect(patchSpy).toHaveBeenCalled() expect(closeSpy).not.toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith('Error saving document', error) }) it('should show save button and save & close or save & next', () => { const nextSpy = jest.spyOn(component, 'hasNext') nextSpy.mockReturnValueOnce(false) fixture.detectChanges() expect( fixture.debugElement .queryAll(By.css('button')) .find((b) => b.nativeElement.textContent === 'Save') ).not.toBeUndefined() expect( fixture.debugElement .queryAll(By.css('button')) .find((b) => b.nativeElement.textContent === 'Save & close') ).not.toBeUndefined() expect( fixture.debugElement .queryAll(By.css('button')) .find((b) => b.nativeElement.textContent === 'Save & next') ).toBeUndefined() nextSpy.mockReturnValue(true) fixture.detectChanges() expect( fixture.debugElement .queryAll(By.css('button')) .find((b) => b.nativeElement.textContent === 'Save & close') ).toBeUndefined() expect( fixture.debugElement .queryAll(By.css('button')) .find((b) => b.nativeElement.textContent === 'Save & next') ).not.toBeUndefined() }) it('should allow close and navigate to documents by default', () => { initNormally() const navigateSpy = jest.spyOn(router, 'navigate') component.close() expect(navigateSpy).toHaveBeenCalledWith(['documents']) }) it('should allow close and navigate to the last view if available', () => { initNormally() jest .spyOn(componentRouterService, 'getComponentURLBefore') .mockReturnValue('dashboard') const navigateSpy = jest.spyOn(router, 'navigate') component.close() expect(navigateSpy).toHaveBeenCalledWith(['dashboard']) }) it('should allow close and navigate to documents by default', () => { initNormally() jest .spyOn(documentListViewService, 'activeSavedViewId', 'get') .mockReturnValue(77) const navigateSpy = jest.spyOn(router, 'navigate') component.close() expect(navigateSpy).toHaveBeenCalledWith(['view', 77]) }) it('should not close if e.g. user-cancelled', () => { initNormally() jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false)) const navigateSpy = jest.spyOn(router, 'navigate') component.close() expect(navigateSpy).not.toHaveBeenCalled() }) it('should support delete, ask for confirmation', () => { initNormally() let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') const deleteSpy = jest.spyOn(documentService, 'delete') deleteSpy.mockReturnValue(of(true)) component.delete() expect(modalSpy).toHaveBeenCalled() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() expect(deleteSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() }) it('should allow retry delete if error', () => { initNormally() let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') const deleteSpy = jest.spyOn(documentService, 'delete') deleteSpy.mockReturnValueOnce(throwError(() => new Error('one time'))) component.delete() expect(modalSpy).toHaveBeenCalled() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() expect(deleteSpy).toHaveBeenCalled() expect(modalCloseSpy).not.toHaveBeenCalled() deleteSpy.mockReturnValueOnce(of(true)) // retry openModal.componentInstance.confirmClicked.next() expect(deleteSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() }) it('should support more like quick filter', () => { initNormally() const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') component.moreLike() expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_FULLTEXT_MORELIKE, value: doc.id.toString(), }, ]) }) it('should support reprocess, confirm and close modal after started', () => { initNormally() const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') bulkEditSpy.mockReturnValue(of(true)) let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') const toastSpy = jest.spyOn(toastService, 'showInfo') component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {}) expect(modalSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() }) it('should show error if redo ocr call fails', () => { initNormally() const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const toastSpy = jest.spyOn(toastService, 'showError') component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred'))) openModal.componentInstance.confirmClicked.next() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).not.toHaveBeenCalled() }) it('should support next doc', () => { initNormally() const serviceSpy = jest.spyOn(documentListViewService, 'getNext') const routerSpy = jest.spyOn(router, 'navigate') serviceSpy.mockReturnValue(of(100)) component.nextDoc() expect(serviceSpy).toHaveBeenCalled() expect(routerSpy).toHaveBeenCalledWith(['documents', 100]) }) it('should support previous doc', () => { initNormally() const serviceSpy = jest.spyOn(documentListViewService, 'getPrevious') const routerSpy = jest.spyOn(router, 'navigate') serviceSpy.mockReturnValue(of(100)) component.previousDoc() expect(serviceSpy).toHaveBeenCalled() expect(routerSpy).toHaveBeenCalledWith(['documents', 100]) }) it('should support password-protected PDFs with a password field', () => { initNormally() component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer expect(component.requiresPassword).toBeTruthy() fixture.detectChanges() expect( fixture.debugElement.query(By.css('input[type=password]')) ).not.toBeUndefined() component.password = 'foo' component.pdfPreviewLoaded({ numPages: 1000 } as any) expect(component.requiresPassword).toBeFalsy() }) it('should support Enter key in password field', () => { initNormally() component.metadata = { has_archive_version: true } component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer fixture.detectChanges() expect(component.password).toBeUndefined() const pwField = fixture.debugElement.query(By.css('input[type=password]')) pwField.nativeElement.value = 'foobar' pwField.nativeElement.dispatchEvent( new KeyboardEvent('keyup', { key: 'Enter' }) ) expect(component.password).toEqual('foobar') }) it('should update n pages after pdf loaded', () => { initNormally() component.pdfPreviewLoaded({ numPages: 1000 } as any) expect(component.previewNumPages).toEqual(1000) }) it('should include delay of 300ms after previewloaded before showing pdf', fakeAsync(() => { initNormally() expect(component.previewLoaded).toBeFalsy() component.pdfPreviewLoaded({ numPages: 1000 } as any) expect(component.previewNumPages).toEqual(1000) tick(300) expect(component.previewLoaded).toBeTruthy() })) it('should support zoom controls', () => { initNormally() component.setZoom(PdfZoomLevel.One) // from select expect(component.previewZoomSetting).toEqual('1') component.increaseZoom() expect(component.previewZoomSetting).toEqual('1.5') component.increaseZoom() expect(component.previewZoomSetting).toEqual('2') component.decreaseZoom() expect(component.previewZoomSetting).toEqual('1.5') component.setZoom(PdfZoomLevel.One) // from select component.decreaseZoom() expect(component.previewZoomSetting).toEqual('.75') component.setZoom(PdfZoomScale.PageFit) // from select expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomSetting).toEqual('1') component.increaseZoom() expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomScale).toEqual('page-width') component.setZoom(PdfZoomScale.PageFit) // from select expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomSetting).toEqual('1') component.decreaseZoom() expect(component.previewZoomSetting).toEqual('.5') expect(component.previewZoomScale).toEqual('page-width') }) it('should select correct zoom setting in dropdown', () => { initNormally() component.setZoom(PdfZoomScale.PageFit) expect(component.currentZoom).toEqual(PdfZoomScale.PageFit) component.setZoom(PdfZoomLevel.Quarter) expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter) }) it('should support updating notes dynamically', () => { const notes = [ { id: 1, note: 'hello world', }, ] initNormally() const refreshSpy = jest.spyOn(openDocumentsService, 'refreshDocument') component.notesUpdated(notes) // called by notes component expect(component.document.notes).toEqual(notes) expect(refreshSpy).toHaveBeenCalled() }) it('should support quick filtering by correspondent', () => { initNormally() const object = { id: 22, name: 'Correspondent22', } as Correspondent const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') component.filterDocuments([object], DataType.Correspondent) expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_CORRESPONDENT, value: object.id.toString(), }, ]) }) it('should support quick filtering by doc type', () => { initNormally() const object = { id: 22, name: 'DocumentType22' } as DocumentType const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') component.filterDocuments([object], DataType.DocumentType) expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_DOCUMENT_TYPE, value: object.id.toString(), }, ]) }) it('should support quick filtering by storage path', () => { initNormally() const object = { id: 22, name: 'StoragePath22', path: '/foo/bar/', } as StoragePath const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') component.filterDocuments([object], DataType.StoragePath) expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_STORAGE_PATH, value: object.id.toString(), }, ]) }) it('should support quick filtering by all tags', () => { initNormally() const object1 = { id: 22, name: 'Tag22', is_inbox_tag: true, color: '#ff0000', text_color: '#000000', } as Tag const object2 = { id: 23, name: 'Tag22', is_inbox_tag: true, color: '#ff0000', text_color: '#000000', } as Tag const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') component.filterDocuments([object1, object2], DataType.Tag) expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_HAS_TAGS_ALL, value: object1.id.toString(), }, { rule_type: FILTER_HAS_TAGS_ALL, value: object2.id.toString(), }, ]) }) it('should support quick filtering by date after - 1d and before +1d', () => { initNormally() const object = { year: 2023, month: 5, day: 14 } as NgbDateStruct const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') component.filterDocuments([object]) expect(qfSpy).toHaveBeenCalledWith([ { rule_type: FILTER_CREATED_AFTER, value: '2023-05-13', }, { rule_type: FILTER_CREATED_BEFORE, value: '2023-05-15', }, ]) }) it('should detect RTL languages and add css class to content textarea', () => { initNormally() component.metadata = { lang: 'he' } component.nav.select(2) // content fixture.detectChanges() expect(component.isRTL).toBeTruthy() expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull() }) it('should display built-in pdf viewer if not disabled', () => { initNormally() component.document.archived_file_name = 'file.pdf' settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) expect(component.useNativePdfViewer).toBeFalsy() fixture.detectChanges() expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull() }) it('should display native pdf viewer if enabled', () => { initNormally() component.document.archived_file_name = 'file.pdf' settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true) expect(component.useNativePdfViewer).toBeTruthy() fixture.detectChanges() expect(fixture.debugElement.query(By.css('object'))).not.toBeNull() }) it('should attempt to retrieve metadata', () => { const metadataSpy = jest.spyOn(documentService, 'getMetadata') metadataSpy.mockReturnValue(of({ has_archive_version: true })) initNormally() expect(metadataSpy).toHaveBeenCalledWith(doc.id, null) }) it('should pass metadata version only for non-latest selected versions', () => { const metadataSpy = jest.spyOn(documentService, 'getMetadata') metadataSpy.mockReturnValue(of({ has_archive_version: true })) initNormally() httpTestingController.expectOne(component.previewUrl).flush('preview') expect(metadataSpy).toHaveBeenCalledWith(doc.id, null) metadataSpy.mockClear() component.document.versions = [ { id: doc.id, is_root: true }, { id: 10, is_root: false }, ] as any jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root') jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root') jest .spyOn(documentService, 'get') .mockReturnValue(of({ content: 'root' } as Document)) component.selectVersion(doc.id) httpTestingController.expectOne('preview-root').flush('root') expect(metadataSpy).toHaveBeenCalledWith(doc.id, doc.id) }) it('should show an error if failed metadata retrieval', () => { const error = new Error('metadata error') jest .spyOn(documentService, 'getMetadata') .mockReturnValue(throwError(() => error)) const toastSpy = jest.spyOn(toastService, 'showError') initNormally() expect(toastSpy).toHaveBeenCalledWith('Error retrieving metadata', error) }) it('should display custom fields', () => { initNormally() expect(fixture.debugElement.nativeElement.textContent).toContain( customFields[0].name ) }) it('should support add custom field, correctly send via post', () => { initNormally() const initialLength = doc.custom_fields.length expect(component.customFieldFormFields).toHaveLength(initialLength) component.addField(customFields[1]) fixture.detectChanges() expect(component.document.custom_fields).toHaveLength(initialLength + 1) expect(component.customFieldFormFields).toHaveLength(initialLength + 1) expect(fixture.debugElement.nativeElement.textContent).toContain( customFields[1].name ) const patchSpy = jest.spyOn(documentService, 'patch') component.save(true) expect(patchSpy.mock.lastCall[0].custom_fields).toHaveLength(2) expect(patchSpy.mock.lastCall[0].custom_fields[1]).toEqual({ field: customFields[1].id, value: null, }) }) it('should support remove custom field, correctly send via post', () => { initNormally() const initialLength = doc.custom_fields.length expect(component.customFieldFormFields).toHaveLength(initialLength) component.removeField(doc.custom_fields[0]) fixture.detectChanges() expect(component.document.custom_fields).toHaveLength(initialLength - 1) expect(component.customFieldFormFields).toHaveLength(initialLength - 1) expect( fixture.debugElement.query(By.css('form ul')).nativeElement.textContent ).not.toContain('Field 1') const patchSpy = jest.spyOn(documentService, 'patch') component.save(true) expect(patchSpy.mock.lastCall[0].custom_fields).toHaveLength( initialLength - 1 ) }) it('should correctly determine changed fields', () => { initNormally() expect(component['getChangedFields']()).toEqual({ id: doc.id, }) component.documentForm.get('title').setValue('Foo Bar') component.documentForm.get('permissions_form').setValue({ owner: 1, set_permissions: { view: { users: [2], groups: [], }, change: { users: [3], groups: [], }, }, }) component.documentForm.get('title').markAsDirty() component.documentForm.get('permissions_form').markAsDirty() expect(component['getChangedFields']()).toEqual({ id: doc.id, title: 'Foo Bar', owner: 1, set_permissions: { view: { users: [2], groups: [], }, change: { users: [3], groups: [], }, }, }) }) it('should restore changed fields and mark as dirty', () => { jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc)) const docWithChanges = Object.assign({}, doc) docWithChanges.__changedFields = ['title', 'tags', 'owner'] jest .spyOn(openDocumentsService, 'getOpenDocument') .mockReturnValue(docWithChanges) fixture.detectChanges() // calls ngOnInit expect(component.documentForm.get('title').dirty).toBeTruthy() expect(component.documentForm.get('tags').dirty).toBeTruthy() expect(component.documentForm.get('permissions_form').dirty).toBeTruthy() }) it('should show custom field errors', () => { initNormally() component.error = { custom_fields: [ {}, {}, { value: ['This field may not be null.'] }, {}, { non_field_errors: ['Enter a valid URL.'] }, ], } expect(component.getCustomFieldError(2)).toEqual([ 'This field may not be null.', ]) expect(component.getCustomFieldError(4)).toEqual(['Enter a valid URL.']) }) it('should refresh custom fields when created', () => { initNormally() const refreshSpy = jest.spyOn(component, 'refreshCustomFields') fixture.debugElement .query(By.directive(CustomFieldsDropdownComponent)) .triggerEventHandler('created') expect(refreshSpy).toHaveBeenCalled() }) it('should get suggestions', () => { const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') suggestionsSpy.mockReturnValue( of({ tags: [42, 43], suggested_tags: [], suggested_document_types: [], suggested_correspondents: [], }) ) initNormally() expect(suggestionsSpy).toHaveBeenCalled() expect(component.suggestions).toEqual({ tags: [42, 43], suggested_tags: [], suggested_document_types: [], suggested_correspondents: [], }) }) it('should show error if needed for get suggestions', () => { const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') const errorSpy = jest.spyOn(toastService, 'showError') suggestionsSpy.mockImplementationOnce(() => throwError(() => new Error('failed to get suggestions')) ) initNormally() expect(suggestionsSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled() }) it('should warn when open document does not match doc retrieved from backend on init', () => { let openModal: NgbModalRef modalService.activeInstances.subscribe((modals) => (openModal = modals[0])) const modalSpy = jest.spyOn(modalService, 'open') const openDoc = Object.assign({}, doc) // simulate a document being modified elsewhere and db updated doc.modified = new Date() jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc)) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ count: customFields.length, all: customFields.map((f) => f.id), results: customFields, }) ) fixture.detectChanges() // calls ngOnInit expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent) const closeSpy = jest.spyOn(openModal, 'close') const confirmDialog = openModal.componentInstance as ConfirmDialogComponent confirmDialog.confirmClicked.next(confirmDialog) expect(closeSpy).toHaveBeenCalled() }) it('should change preview element by render type', () => { initNormally() component.document.archived_file_name = 'file.pdf' fixture.detectChanges() expect(component.archiveContentRenderType).toEqual( component.ContentRenderType.PDF ) expect( fixture.debugElement.query(By.css('pdf-viewer-container')) ).not.toBeUndefined() component.document.archived_file_name = undefined component.document.mime_type = 'text/plain' fixture.detectChanges() expect(component.archiveContentRenderType).toEqual( component.ContentRenderType.Text ) expect( fixture.debugElement.query(By.css('div.preview-sticky')) ).not.toBeUndefined() component.document.mime_type = 'image/jpeg' fixture.detectChanges() expect(component.archiveContentRenderType).toEqual( component.ContentRenderType.Image ) expect( fixture.debugElement.query(By.css('.preview-sticky img')) ).not.toBeUndefined() ;(component.document.mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), fixture.detectChanges() expect(component.archiveContentRenderType).toEqual( component.ContentRenderType.Other ) expect( fixture.debugElement.query(By.css('object.preview-sticky')) ).not.toBeUndefined() }) it('should support pdf editor, handle error', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[0])) const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') const errorSpy = jest.spyOn(toastService, 'showError') initNormally() component.editPdf() expect(modal).not.toBeUndefined() modal.componentInstance.documentID = doc.id modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] modal.componentInstance.confirm() let req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/bulk_edit/` ) expect(req.request.body).toEqual({ documents: [doc.id], method: 'edit_pdf', parameters: { operations: [{ page: 1, rotate: 0, doc: 0 }], delete_original: false, update_document: false, include_metadata: true, }, }) req.error(new ErrorEvent('failed')) expect(errorSpy).toHaveBeenCalled() component.editPdf() modal.componentInstance.documentID = doc.id modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }] modal.componentInstance.deleteOriginal = true modal.componentInstance.confirm() req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/bulk_edit/` ) req.flush(true) expect(closeSpy).toHaveBeenCalled() }) it('should support removing password protection from pdfs', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[0])) initNormally() component.password = 'secret' component.removePassword() const dialog = modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.updateDocument = false dialog.includeMetadata = false dialog.deleteOriginal = true dialog.confirm() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/bulk_edit/` ) expect(req.request.body).toEqual({ documents: [doc.id], method: 'remove_password', parameters: { password: 'secret', update_document: false, include_metadata: false, delete_original: true, }, }) req.flush(true) }) it('should require the current password before removing it', () => { initNormally() const errorSpy = jest.spyOn(toastService, 'showError') component.requiresPassword = true component.password = '' component.removePassword() expect(errorSpy).toHaveBeenCalled() httpTestingController.expectNone( `${environment.apiBaseUrl}documents/bulk_edit/` ) }) it('should handle failures when removing password protection', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[0])) initNormally() const errorSpy = jest.spyOn(toastService, 'showError') component.password = 'secret' component.removePassword() const dialog = modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.confirm() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/bulk_edit/` ) req.error(new ErrorEvent('failed')) expect(errorSpy).toHaveBeenCalled() expect(component.networkActive).toBe(false) expect(dialog.buttonsEnabled).toBe(true) }) it('should refresh the document when removing password in update mode', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[0])) const refreshSpy = jest.spyOn(openDocumentsService, 'refreshDocument') initNormally() component.password = 'secret' component.removePassword() const dialog = modal.componentInstance as PasswordRemovalConfirmDialogComponent dialog.confirm() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/bulk_edit/` ) req.flush(true) expect(refreshSpy).toHaveBeenCalledWith(doc.id) }) it('should support keyboard shortcuts', () => { initNormally() const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true) const nextSpy = jest.spyOn(component, 'nextDoc') document.dispatchEvent( new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true }) ) expect(nextSpy).toHaveBeenCalled() jest.spyOn(component, 'hasPrevious').mockReturnValue(true) const prevSpy = jest.spyOn(component, 'previousDoc') document.dispatchEvent( new KeyboardEvent('keydown', { key: 'arrowleft', ctrlKey: true }) ) expect(prevSpy).toHaveBeenCalled() const isDirtySpy = jest .spyOn(openDocumentsService, 'isDirty') .mockReturnValue(true) const saveSpy = jest.spyOn(component, 'save') document.dispatchEvent( new KeyboardEvent('keydown', { key: 's', ctrlKey: true }) ) expect(saveSpy).toHaveBeenCalled() hasNextSpy.mockReturnValue(true) const saveNextSpy = jest.spyOn(component, 'saveEditNext') document.dispatchEvent( new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true }) ) expect(saveNextSpy).toHaveBeenCalled() saveSpy.mockClear() saveNextSpy.mockClear() isDirtySpy.mockReturnValue(true) hasNextSpy.mockReturnValue(false) document.dispatchEvent( new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true }) ) expect(saveNextSpy).not.toHaveBeenCalled() expect(saveSpy).toHaveBeenCalledWith(true) const closeSpy = jest.spyOn(component, 'close') document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' })) expect(closeSpy).toHaveBeenCalled() }) it('selectVersion should update preview and handle preview failures', () => { const previewSpy = jest.spyOn(documentService, 'getPreviewUrl') initNormally() httpTestingController.expectOne(component.previewUrl).flush('preview') previewSpy.mockReturnValueOnce('preview-version') jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-version') jest .spyOn(documentService, 'get') .mockReturnValue(of({ content: 'version-content' } as Document)) component.selectVersion(10) httpTestingController.expectOne('preview-version').flush('version text') expect(component.previewUrl).toBe('preview-version') expect(component.thumbUrl).toBe('thumb-version') expect(component.previewText).toBe('version text') expect(component.documentForm.get('content').value).toBe('version-content') const pdfSource = component.pdfSource as { url: string; password?: string } expect(pdfSource.url).toBe('preview-version') expect(pdfSource.password).toBeUndefined() previewSpy.mockReturnValueOnce('preview-error') component.selectVersion(11) httpTestingController .expectOne('preview-error') .error(new ErrorEvent('fail')) expect(component.previewText).toContain('An error occurred loading content') }) it('deleteVersion should update versions, fall back, and surface errors', () => { initNormally() httpTestingController.expectOne(component.previewUrl).flush('preview') component.document.versions = [ { id: 3, added: new Date(), version_label: 'Original', checksum: 'aaaa', is_root: true, }, { id: 10, added: new Date(), version_label: 'Edited', checksum: 'bbbb', is_root: false, }, ] component.selectedVersionId = 10 const openDoc = { ...doc, versions: [] } as Document jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) const saveSpy = jest.spyOn(openDocumentsService, 'save') const deleteSpy = jest.spyOn(documentService, 'deleteVersion') const versionsSpy = jest.spyOn(documentService, 'getVersions') const selectSpy = jest .spyOn(component, 'selectVersion') .mockImplementation(() => {}) const errorSpy = jest.spyOn(toastService, 'showError') deleteSpy.mockReturnValueOnce(of({ result: 'ok', current_version_id: 99 })) versionsSpy.mockReturnValueOnce( of({ id: doc.id, versions: [{ id: 99, is_root: false }] } as Document) ) component.deleteVersion(10) expect(component.document.versions).toEqual([{ id: 99, is_root: false }]) expect(openDoc.versions).toEqual([{ id: 99, is_root: false }]) expect(saveSpy).toHaveBeenCalled() expect(selectSpy).toHaveBeenCalledWith(99) component.selectedVersionId = 3 deleteSpy.mockReturnValueOnce( of({ result: 'ok', current_version_id: null }) ) versionsSpy.mockReturnValueOnce( of({ id: doc.id, versions: [ { id: 7, is_root: false }, { id: 9, is_root: false }, ], } as Document) ) component.deleteVersion(3) expect(selectSpy).toHaveBeenCalledWith(component.documentId) deleteSpy.mockReturnValueOnce(throwError(() => new Error('nope'))) component.deleteVersion(10) expect(errorSpy).toHaveBeenCalled() }) it('onVersionFileSelected should cover upload flows and reset status', () => { initNormally() httpTestingController.expectOne(component.previewUrl).flush('preview') const uploadSpy = jest.spyOn(documentService, 'uploadVersion') const versionsSpy = jest.spyOn(documentService, 'getVersions') const infoSpy = jest.spyOn(toastService, 'showInfo') const errorSpy = jest.spyOn(toastService, 'showError') const finishedSpy = jest.spyOn( websocketStatusService, 'onDocumentConsumptionFinished' ) const failedSpy = jest.spyOn( websocketStatusService, 'onDocumentConsumptionFailed' ) const selectSpy = jest .spyOn(component, 'selectVersion') .mockImplementation(() => {}) const openDoc = { ...doc, versions: [] } as Document jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) const saveSpy = jest.spyOn(openDocumentsService, 'save') component.onVersionFileSelected({ target: createFileInput() } as any) expect(uploadSpy).not.toHaveBeenCalled() const fileMissing = new File(['data'], 'version.pdf', { type: 'application/pdf', }) component.newVersionLabel = ' label ' uploadSpy.mockReturnValueOnce(of({})) component.onVersionFileSelected({ target: createFileInput(fileMissing), } as any) expect(uploadSpy).toHaveBeenCalledWith( component.documentId, fileMissing, 'label' ) expect(component.newVersionLabel).toBe('') expect(component.versionUploadState).toBe(UploadState.Failed) expect(component.versionUploadError).toBe('Missing task ID.') expect(infoSpy).toHaveBeenCalled() const finishedFail$ = new Subject() const failedFail$ = new Subject() finishedSpy.mockReturnValueOnce(finishedFail$ as any) failedSpy.mockReturnValueOnce(failedFail$ as any) uploadSpy.mockReturnValueOnce(of('task-1')) component.onVersionFileSelected({ target: createFileInput( new File(['data'], 'version.pdf', { type: 'application/pdf' }) ), } as any) expect(component.versionUploadState).toBe(UploadState.Processing) failedFail$.next({ taskId: 'task-1', message: 'nope' }) expect(component.versionUploadState).toBe(UploadState.Failed) expect(component.versionUploadError).toBe('nope') expect(versionsSpy).not.toHaveBeenCalled() const finishedOk$ = new Subject() const failedOk$ = new Subject() finishedSpy.mockReturnValueOnce(finishedOk$ as any) failedSpy.mockReturnValueOnce(failedOk$ as any) uploadSpy.mockReturnValueOnce(of({ task_id: 'task-2' })) const versions = [ { id: 7, is_root: false }, { id: 12, is_root: false }, ] as any versionsSpy.mockReturnValueOnce(of({ id: doc.id, versions } as Document)) component.onVersionFileSelected({ target: createFileInput( new File(['data'], 'version.pdf', { type: 'application/pdf' }) ), } as any) finishedOk$.next({ taskId: 'task-2' }) expect(component.document.versions).toEqual(versions) expect(openDoc.versions).toEqual(versions) expect(saveSpy).toHaveBeenCalled() expect(selectSpy).toHaveBeenCalledWith(12) expect(component.versionUploadState).toBe(UploadState.Idle) expect(component.versionUploadError).toBeNull() component.versionUploadState = UploadState.Failed component.versionUploadError = 'boom' component.clearVersionUploadStatus() expect(component.versionUploadState).toBe(UploadState.Idle) expect(component.versionUploadError).toBeNull() uploadSpy.mockReturnValueOnce(throwError(() => new Error('upload blew up'))) component.onVersionFileSelected({ target: createFileInput( new File(['data'], 'version.pdf', { type: 'application/pdf' }) ), } as any) expect(component.versionUploadState).toBe(UploadState.Failed) expect(component.versionUploadError).toBe('upload blew up') expect(errorSpy).toHaveBeenCalled() }) it('should clear and isolate version upload state on document change', () => { initNormally() httpTestingController.expectOne(component.previewUrl).flush('preview') component.versionUploadState = UploadState.Failed component.versionUploadError = 'boom' component.docChangeNotifier.next(999) expect(component.versionUploadState).toBe(UploadState.Idle) expect(component.versionUploadError).toBeNull() const uploadSpy = jest.spyOn(documentService, 'uploadVersion') const versionsSpy = jest.spyOn(documentService, 'getVersions') const finished$ = new Subject() const failed$ = new Subject() jest .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValueOnce(finished$ as any) jest .spyOn(websocketStatusService, 'onDocumentConsumptionFailed') .mockReturnValueOnce(failed$ as any) uploadSpy.mockReturnValueOnce(of('task-stale')) component.onVersionFileSelected({ target: createFileInput( new File(['data'], 'version.pdf', { type: 'application/pdf' }) ), } as any) expect(component.versionUploadState).toBe(UploadState.Processing) component.docChangeNotifier.next(1000) failed$.next({ taskId: 'task-stale', message: 'stale-error' }) expect(component.versionUploadState).toBe(UploadState.Idle) expect(component.versionUploadError).toBeNull() expect(versionsSpy).not.toHaveBeenCalled() }) it('createDisabled should return true if the user does not have permission to add the specified data type', () => { currentUserCan = false expect(component.createDisabled(DataType.Correspondent)).toBeTruthy() expect(component.createDisabled(DataType.DocumentType)).toBeTruthy() expect(component.createDisabled(DataType.StoragePath)).toBeTruthy() expect(component.createDisabled(DataType.Tag)).toBeTruthy() }) it('createDisabled should return false if the user has permission to add the specified data type', () => { currentUserCan = true expect(component.createDisabled(DataType.Correspondent)).toBeFalsy() expect(component.createDisabled(DataType.DocumentType)).toBeFalsy() expect(component.createDisabled(DataType.StoragePath)).toBeFalsy() expect(component.createDisabled(DataType.Tag)).toBeFalsy() }) it('should call tryRenderTiff when no archive and file is tiff', () => { initNormally() const tiffRenderSpy = jest.spyOn( DocumentDetailComponent.prototype as any, 'tryRenderTiff' ) const doc = Object.assign({}, component.document) doc.archived_file_name = null doc.mime_type = 'image/tiff' jest .spyOn(documentService, 'getMetadata') .mockReturnValue( of({ has_archive_version: false, original_mime_type: 'image/tiff' }) ) component.updateComponent(doc) fixture.detectChanges() expect(component.archiveContentRenderType).toEqual( component.ContentRenderType.TIFF ) expect(tiffRenderSpy).toHaveBeenCalled() }) it('should try to render tiff and show error if failed', () => { initNormally() // just the text request httpTestingController.expectOne(component.previewUrl) // invalid tiff component['tryRenderTiff']() httpTestingController .expectOne(component.previewUrl) .flush(new ArrayBuffer(100)) // arraybuffer expect(component.tiffError).not.toBeUndefined() // http error component['tryRenderTiff']() httpTestingController .expectOne(component.previewUrl) .error(new ErrorEvent('failed')) expect(component.tiffError).not.toBeUndefined() }) it('should support download using share sheet on mobile, direct download otherwise', () => { const shareSpy = jest.spyOn(navigator, 'share') const createSpy = jest.spyOn(document, 'createElement') const urlRevokeSpy = jest.spyOn(URL, 'revokeObjectURL') initNormally() // Mobile jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(false) component.download() httpTestingController .expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`) .error(new ProgressEvent('failed')) expect(shareSpy).not.toHaveBeenCalled() component.download(true) httpTestingController .expectOne( `${environment.apiBaseUrl}documents/${doc.id}/download/?original=true` ) .flush(new ArrayBuffer(100)) expect(shareSpy).toHaveBeenCalled() // Desktop shareSpy.mockClear() jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(true) component.download() httpTestingController .expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`) .flush(new ArrayBuffer(100)) expect(shareSpy).not.toHaveBeenCalled() expect(createSpy).toHaveBeenCalledWith('a') expect(urlRevokeSpy).toHaveBeenCalled() }) it('should include version in download and print only for non-latest selected version', () => { initNormally() component.document.versions = [ { id: doc.id, is_root: true }, { id: 10, is_root: false }, ] as any const getDownloadUrlSpy = jest .spyOn(documentService, 'getDownloadUrl') .mockReturnValueOnce('download-latest') .mockReturnValueOnce('print-latest') .mockReturnValueOnce('download-non-latest') .mockReturnValueOnce('print-non-latest') component.selectedVersionId = 10 component.download() expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(1, doc.id, false, null) httpTestingController .expectOne('download-latest') .error(new ProgressEvent('failed')) component.printDocument() expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(2, doc.id, false, null) httpTestingController .expectOne('print-latest') .error(new ProgressEvent('failed')) component.selectedVersionId = doc.id component.download() expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(3, doc.id, false, doc.id) httpTestingController .expectOne('download-non-latest') .error(new ProgressEvent('failed')) component.printDocument() expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(4, doc.id, false, doc.id) httpTestingController .expectOne('print-non-latest') .error(new ProgressEvent('failed')) }) it('should omit version in download and print when no version is selected', () => { initNormally() component.document.versions = [] as any ;(component as any).selectedVersionId = undefined const getDownloadUrlSpy = jest .spyOn(documentService, 'getDownloadUrl') .mockReturnValueOnce('download-no-version') .mockReturnValueOnce('print-no-version') component.download() expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(1, doc.id, false, null) httpTestingController .expectOne('download-no-version') .error(new ProgressEvent('failed')) component.printDocument() expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(2, doc.id, false, null) httpTestingController .expectOne('print-no-version') .error(new ProgressEvent('failed')) }) it('should download a file with the correct filename', () => { const mockBlob = new Blob(['test content'], { type: 'text/plain' }) const mockResponse = new HttpResponse({ body: mockBlob, headers: new HttpHeaders({ 'Content-Disposition': 'attachment; filename="test-file.txt"', }), }) const downloadUrl = 'http://example.com/download' component.documentId = 123 jest.spyOn(documentService, 'getDownloadUrl').mockReturnValue(downloadUrl) const createSpy = jest.spyOn(document, 'createElement') const anchor: HTMLAnchorElement = {} as HTMLAnchorElement createSpy.mockReturnValueOnce(anchor) component.download(false) httpTestingController .expectOne(downloadUrl) .flush(mockBlob, { headers: mockResponse.headers }) expect(createSpy).toHaveBeenCalledWith('a') expect(anchor.download).toBe('test-file.txt') createSpy.mockClear() }) it('should get email enabled status from settings', () => { jest.spyOn(settingsService, 'get').mockReturnValue(true) expect(component.emailEnabled).toBeTruthy() }) it('should support open share links and email modals', () => { const modalSpy = jest.spyOn(modalService, 'open') initNormally() component.openShareLinks() expect(modalSpy).toHaveBeenCalled() component.openEmailDocument() expect(modalSpy).toHaveBeenCalled() }) it('should set previewText', () => { initNormally() const previewText = 'Hello world, this is a test' httpTestingController.expectOne(component.previewUrl).flush(previewText) expect(component.previewText).toEqual(previewText) }) it('should set previewText to error message if preview fails', () => { initNormally() httpTestingController .expectOne(component.previewUrl) .flush('fail', { status: 500, statusText: 'Server Error' }) expect(component.previewText).toContain('An error occurred loading content') }) it('should print document successfully', fakeAsync(() => { initNormally() const appendChildSpy = jest .spyOn(document.body, 'appendChild') .mockImplementation((node: Node) => node) const removeChildSpy = jest .spyOn(document.body, 'removeChild') .mockImplementation((node: Node) => node) const createObjectURLSpy = jest .spyOn(URL, 'createObjectURL') .mockReturnValue('blob:mock-url') const revokeObjectURLSpy = jest .spyOn(URL, 'revokeObjectURL') .mockImplementation(() => {}) const mockContentWindow = { focus: jest.fn(), print: jest.fn(), onafterprint: null, } const mockIframe = { style: {}, src: '', onload: null, contentWindow: mockContentWindow, } const createElementSpy = jest .spyOn(document, 'createElement') .mockReturnValue(mockIframe as any) const blob = new Blob(['test'], { type: 'application/pdf' }) component.printDocument() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/${doc.id}/download/` ) req.flush(blob) tick() expect(createElementSpy).toHaveBeenCalledWith('iframe') expect(appendChildSpy).toHaveBeenCalledWith(mockIframe) expect(createObjectURLSpy).toHaveBeenCalledWith(blob) if (mockIframe.onload) { mockIframe.onload({} as any) } expect(mockContentWindow.focus).toHaveBeenCalled() expect(mockContentWindow.print).toHaveBeenCalled() if (mockIframe.onload) { mockIframe.onload(new Event('load')) } if (mockContentWindow.onafterprint) { mockContentWindow.onafterprint(new Event('afterprint')) } tick(500) expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') createElementSpy.mockRestore() appendChildSpy.mockRestore() removeChildSpy.mockRestore() createObjectURLSpy.mockRestore() revokeObjectURLSpy.mockRestore() })) it('should show error toast if print document fails', () => { initNormally() const toastSpy = jest.spyOn(toastService, 'showError') component.printDocument() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/${doc.id}/download/` ) req.error(new ErrorEvent('failed')) expect(toastSpy).toHaveBeenCalledWith( 'Error loading document for printing.' ) }) const iframePrintErrorCases: Array<{ description: string thrownError: Error expectToast: boolean }> = [ { description: 'should show error toast if printing throws inside iframe', thrownError: new Error('focus failed'), expectToast: true, }, { description: 'should suppress toast if cross-origin afterprint error occurs', thrownError: new DOMException( 'Accessing onafterprint triggered a cross-origin violation', 'SecurityError' ), expectToast: false, }, ] iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => { it( description, fakeAsync(() => { initNormally() const appendChildSpy = jest .spyOn(document.body, 'appendChild') .mockImplementation((node: Node) => node) const removeChildSpy = jest .spyOn(document.body, 'removeChild') .mockImplementation((node: Node) => node) const createObjectURLSpy = jest .spyOn(URL, 'createObjectURL') .mockReturnValue('blob:mock-url') const revokeObjectURLSpy = jest .spyOn(URL, 'revokeObjectURL') .mockImplementation(() => {}) const toastSpy = jest.spyOn(toastService, 'showError') const mockContentWindow = { focus: jest.fn().mockImplementation(() => { throw thrownError }), print: jest.fn(), onafterprint: null, } const mockIframe: any = { style: {}, src: '', onload: null, contentWindow: mockContentWindow, } const createElementSpy = jest .spyOn(document, 'createElement') .mockReturnValue(mockIframe as any) const blob = new Blob(['test'], { type: 'application/pdf' }) component.printDocument() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/${doc.id}/download/` ) req.flush(blob) tick() if (mockIframe.onload) { mockIframe.onload(new Event('load')) } tick(200) if (expectToast) { expect(toastSpy).toHaveBeenCalled() } else { expect(toastSpy).not.toHaveBeenCalled() } expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') createElementSpy.mockRestore() appendChildSpy.mockRestore() removeChildSpy.mockRestore() createObjectURLSpy.mockRestore() revokeObjectURLSpy.mockRestore() }) ) }) })