import { ViewportScroller, DatePipe } from '@angular/common' import { HttpClientTestingModule } from '@angular/common/http/testing' import { ComponentFixture, TestBed, fakeAsync, tick, } from '@angular/core/testing' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { By } from '@angular/platform-browser' import { Router, ActivatedRoute, convertToParamMap } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' import { NgbModal, NgbModule, NgbAlertModule, NgbNavLink, NgbModalRef, } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' import { of, throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account' import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' import { PermissionsService } from 'src/app/services/permissions.service' import { GroupService } from 'src/app/services/rest/group.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService, Toast } from 'src/app/services/toast.service' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component' import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' import { CheckComponent } from '../../common/input/check/check.component' import { ColorComponent } from '../../common/input/color/color.component' import { NumberComponent } from '../../common/input/number/number.component' import { PasswordComponent } from '../../common/input/password/password.component' import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component' import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component' import { SelectComponent } from '../../common/input/select/select.component' import { TagsComponent } from '../../common/input/tags/tags.component' import { TextComponent } from '../../common/input/text/text.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { SettingsComponent } from './settings.component' const savedViews = [ { id: 1, name: 'view1' }, { id: 2, name: 'view2' }, ] const users = [ { id: 1, username: 'user1', is_superuser: false }, { id: 2, username: 'user2', is_superuser: false }, ] const groups = [ { id: 1, name: 'group1' }, { id: 2, name: 'group2' }, ] const mailAccounts = [ { id: 1, name: 'account1' }, { id: 2, name: 'account2' }, ] const mailRules = [ { id: 1, name: 'rule1', owner: 1 }, { id: 2, name: 'rule2', owner: 2 }, ] describe('SettingsComponent', () => { let component: SettingsComponent let fixture: ComponentFixture let modalService: NgbModal let router: Router let settingsService: SettingsService let savedViewService: SavedViewService let activatedRoute: ActivatedRoute let viewportScroller: ViewportScroller let toastService: ToastService let userService: UserService let permissionsService: PermissionsService let groupService: GroupService let mailAccountService: MailAccountService let mailRuleService: MailRuleService beforeEach(async () => { TestBed.configureTestingModule({ declarations: [ SettingsComponent, PageHeaderComponent, IfPermissionsDirective, CustomDatePipe, ConfirmDialogComponent, CheckComponent, ColorComponent, SafeHtmlPipe, SelectComponent, TextComponent, PasswordComponent, NumberComponent, TagsComponent, MailAccountEditDialogComponent, MailRuleEditDialogComponent, PermissionsUserComponent, PermissionsGroupComponent, ], providers: [CustomDatePipe, DatePipe, PermissionsGuard], imports: [ NgbModule, HttpClientTestingModule, RouterTestingModule.withRoutes(routes), FormsModule, ReactiveFormsModule, NgbAlertModule, NgSelectModule, ], }).compileComponents() modalService = TestBed.inject(NgbModal) router = TestBed.inject(Router) activatedRoute = TestBed.inject(ActivatedRoute) viewportScroller = TestBed.inject(ViewportScroller) toastService = TestBed.inject(ToastService) settingsService = TestBed.inject(SettingsService) settingsService.currentUser = { id: 99, username: 'user99' } userService = TestBed.inject(UserService) permissionsService = TestBed.inject(PermissionsService) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest .spyOn(permissionsService, 'currentUserHasObjectPermissions') .mockReturnValue(true) jest .spyOn(permissionsService, 'currentUserOwnsObject') .mockReturnValue(true) groupService = TestBed.inject(GroupService) savedViewService = TestBed.inject(SavedViewService) mailAccountService = TestBed.inject(MailAccountService) mailRuleService = TestBed.inject(MailRuleService) }) function completeSetup(excludeService = null) { if (excludeService !== userService) { jest.spyOn(userService, 'listAll').mockReturnValue( of({ all: users.map((u) => u.id), count: users.length, results: users.concat([]), }) ) } if (excludeService !== groupService) { jest.spyOn(groupService, 'listAll').mockReturnValue( of({ all: groups.map((g) => g.id), count: groups.length, results: groups.concat([]), }) ) } if (excludeService !== savedViewService) { jest.spyOn(savedViewService, 'listAll').mockReturnValue( of({ all: savedViews.map((v) => v.id), count: savedViews.length, results: (savedViews as PaperlessSavedView[]).concat([]), }) ) } if (excludeService !== mailAccountService) { jest.spyOn(mailAccountService, 'listAll').mockReturnValue( of({ all: mailAccounts.map((a) => a.id), count: mailAccounts.length, results: (mailAccounts as PaperlessMailAccount[]).concat([]), }) ) } if (excludeService !== mailRuleService) { jest.spyOn(mailRuleService, 'listAll').mockReturnValue( of({ all: mailRules.map((r) => r.id), count: mailRules.length, results: (mailRules as PaperlessMailRule[]).concat([]), }) ) } fixture = TestBed.createComponent(SettingsComponent) component = fixture.componentInstance fixture.detectChanges() } it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => { completeSetup() const navigateSpy = jest.spyOn(router, 'navigate') const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'savedviews']) tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'mail']) tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'usersgroups']) const initSpy = jest.spyOn(component, 'initialize') component.isDirty = true // mock dirty navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click')) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general']) expect(initSpy).not.toHaveBeenCalled() navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) expect(initSpy).toHaveBeenCalled() }) it('should support direct link to tab by URL, scroll if needed', () => { completeSetup() jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ section: 'mail' }))) activatedRoute.snapshot.fragment = '#mail' const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') component.ngOnInit() expect(component.activeNavID).toEqual(4) // Mail component.ngAfterViewInit() expect(scrollSpy).toHaveBeenCalledWith('#mail') }) it('should lazy load tab data', () => { completeSetup() const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) expect(component.savedViews).toBeUndefined() tabButtons[2].nativeElement.dispatchEvent( new MouseEvent('mouseover', { bubbles: true }) ) expect(component.savedViews).not.toBeUndefined() expect(component.mailAccounts).toBeUndefined() tabButtons[3].nativeElement.dispatchEvent( new MouseEvent('mouseover', { bubbles: true }) ) expect(component.mailAccounts).not.toBeUndefined() expect(component.groups).toBeUndefined() tabButtons[4].nativeElement.dispatchEvent( new MouseEvent('mouseover', { bubbles: true }) ) expect(component.groups).not.toBeUndefined() }) it('should support save saved views, show error', () => { completeSetup() component.maybeInitializeTab(3) // SavedViews const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'show') const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany') // saved views error first savedViewPatchSpy.mockReturnValueOnce( throwError(() => new Error('unable to save saved views')) ) component.saveSettings() expect(toastErrorSpy).toHaveBeenCalled() expect(savedViewPatchSpy).toHaveBeenCalled() toastSpy.mockClear() toastErrorSpy.mockClear() savedViewPatchSpy.mockClear() // succeed saved views savedViewPatchSpy.mockReturnValueOnce( of(savedViews as PaperlessSavedView[]) ) component.saveSettings() expect(toastErrorSpy).not.toHaveBeenCalled() expect(savedViewPatchSpy).toHaveBeenCalled() }) it('should support save local settings updating appearance settings and calling API, show error', () => { completeSetup() const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'show') const storeSpy = jest.spyOn(settingsService, 'storeSettings') const appearanceSettingsSpy = jest.spyOn( settingsService, 'updateAppearanceSettings' ) const setSpy = jest.spyOn(settingsService, 'set') // error first storeSpy.mockReturnValueOnce( throwError(() => new Error('unable to save settings')) ) component.saveSettings() expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(setSpy).toHaveBeenCalledTimes(24) // succeed storeSpy.mockReturnValueOnce(of(true)) component.saveSettings() expect(toastSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).toHaveBeenCalled() }) it('should offer reload if settings changes require', () => { completeSetup() let toast: Toast toastService.getToasts().subscribe((t) => (toast = t[0])) component.initialize(true) // reset component.store.getValue()['displayLanguage'] = 'en-US' component.store.getValue()['updateCheckingEnabled'] = false component.settingsForm.value.displayLanguage = 'en-GB' component.settingsForm.value.updateCheckingEnabled = true jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true)) component.saveSettings() expect(toast.actionName).toEqual('Reload now') }) it('should allow setting theme color, visually apply change immediately but not save', () => { completeSetup() const appearanceSpy = jest.spyOn( settingsService, 'updateAppearanceSettings' ) const colorInput = fixture.debugElement.query(By.directive(ColorComponent)) colorInput.query(By.css('input')).nativeElement.value = '#ff0000' colorInput .query(By.css('input')) .nativeElement.dispatchEvent(new Event('change')) fixture.detectChanges() expect(appearanceSpy).toHaveBeenCalled() expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('') component.clearThemeColor() }) it('should support delete saved view', () => { completeSetup() component.maybeInitializeTab(3) // SavedViews const toastSpy = jest.spyOn(toastService, 'showInfo') const deleteSpy = jest.spyOn(savedViewService, 'delete') deleteSpy.mockReturnValue(of(true)) component.deleteSavedView(savedViews[0] as PaperlessSavedView) expect(deleteSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith( `Saved view "${savedViews[0].name}" deleted.` ) }) it('should support edit / create user, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) const editDialog = modal.componentInstance as UserEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') editDialog.failed.emit() expect(toastErrorSpy).toBeCalled() settingsService.currentUser = users[1] // simulate logged in as different user editDialog.succeeded.emit(users[0]) expect(toastInfoSpy).toHaveBeenCalledWith( `Saved user "${users[0].username}".` ) }) it('should support delete user, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteUser(users[0]) const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(userService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const listAllSpy = jest.spyOn(userService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting user')) ) deleteDialog.confirm() expect(toastErrorSpy).toBeCalled() deleteSpy.mockReturnValueOnce(of(true)) deleteDialog.confirm() expect(listAllSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user') }) it('should logout current user if password changed, after delay', fakeAsync(() => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) const editDialog = modal.componentInstance as UserEditDialogComponent editDialog.passwordIsSet = true settingsService.currentUser = users[0] // simulate logged in as same user editDialog.succeeded.emit(users[0]) fixture.detectChanges() Object.defineProperty(window, 'location', { value: { href: 'http://localhost/', }, writable: true, // possibility to override }) tick(2600) expect(window.location.href).toContain('logout') })) it('should support edit / create group, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editGroup(groups[0]) const editDialog = modal.componentInstance as GroupEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') editDialog.failed.emit() expect(toastErrorSpy).toBeCalled() editDialog.succeeded.emit(groups[0]) expect(toastInfoSpy).toHaveBeenCalledWith( `Saved group "${groups[0].name}".` ) }) it('should support delete group, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteGroup(users[0]) const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(groupService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const listAllSpy = jest.spyOn(groupService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting group')) ) deleteDialog.confirm() expect(toastErrorSpy).toBeCalled() deleteSpy.mockReturnValueOnce(of(true)) deleteDialog.confirm() expect(listAllSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group') }) it('should get group name', () => { completeSetup() component.maybeInitializeTab(5) // UsersGroups expect(component.getGroupName(1)).toEqual(groups[0].name) expect(component.getGroupName(11)).toEqual('') }) it('should show errors on load if load mailAccounts failure', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') jest .spyOn(mailAccountService, 'listAll') .mockImplementation(() => throwError(() => new Error('failed to load mail accounts')) ) completeSetup(mailAccountService) const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab fixture.detectChanges() expect(toastErrorSpy).toBeCalled() }) it('should show errors on load if load mailRules failure', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') jest .spyOn(mailRuleService, 'listAll') .mockImplementation(() => throwError(() => new Error('failed to load mail rules')) ) completeSetup(mailRuleService) const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab fixture.detectChanges() // tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) expect(toastErrorSpy).toBeCalled() }) it('should show errors on load if load users failure', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') jest .spyOn(userService, 'listAll') .mockImplementation(() => throwError(() => new Error('failed to load users')) ) completeSetup(userService) const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab fixture.detectChanges() expect(toastErrorSpy).toBeCalled() }) it('should show errors on load if load groups failure', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') jest .spyOn(groupService, 'listAll') .mockImplementation(() => throwError(() => new Error('failed to load groups')) ) completeSetup(groupService) const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab fixture.detectChanges() expect(toastErrorSpy).toBeCalled() }) it('should support edit / create mail account, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailAccount(mailAccounts[0] as PaperlessMailAccount) const editDialog = modal.componentInstance as MailAccountEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') editDialog.failed.emit() expect(toastErrorSpy).toBeCalled() editDialog.succeeded.emit(mailAccounts[0]) expect(toastInfoSpy).toHaveBeenCalledWith( `Saved account "${mailAccounts[0].name}".` ) }) it('should support delete mail account, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount) const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(mailAccountService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const listAllSpy = jest.spyOn(mailAccountService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting mail account')) ) deleteDialog.confirm() expect(toastErrorSpy).toBeCalled() deleteSpy.mockReturnValueOnce(of(true)) deleteDialog.confirm() expect(listAllSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account') }) it('should support edit / create mail rule, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailRule(mailRules[0] as PaperlessMailRule) const editDialog = modal.componentInstance as MailRuleEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') editDialog.failed.emit() expect(toastErrorSpy).toBeCalled() editDialog.succeeded.emit(mailRules[0]) expect(toastInfoSpy).toHaveBeenCalledWith( `Saved rule "${mailRules[0].name}".` ) }) it('should support delete mail rule, show error if needed', () => { completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailRule(mailRules[0] as PaperlessMailRule) const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(mailRuleService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const listAllSpy = jest.spyOn(mailRuleService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting mail rule')) ) deleteDialog.confirm() expect(toastErrorSpy).toBeCalled() deleteSpy.mockReturnValueOnce(of(true)) deleteDialog.confirm() expect(listAllSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule') }) })