]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
frontend unit tests
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 23 May 2023 22:02:54 +0000 (15:02 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Fri, 16 Jun 2023 06:53:04 +0000 (23:53 -0700)
toasts component testing

conditional import of angular setup-jest for vscode-jest support

Update jest.config.js

Create open-documents.service.spec.ts

Add unit tests for all REST services

settings service test

Remove component from settings service test

Create permissions.service.spec.ts

upload documents service tests

Update package.json

Create toast.service.spec.ts

Tasks service test

Statistics widget component tests

Update permissions.service.ts

Create app.component.spec.ts

settings component testing

tasks component unit testing

Management list component generic tests

Some management component tests

document notes component unit tests

Create document-list.component.spec.ts

Create save-view-config-dialog.component.spec.ts

Create filter-editor.component.spec.ts

small and large document cards unit testing

Create bulk-editor.component.spec.ts

document detail unit tests

saving work on documentdetail component spec

Create document-asn.component.spec.ts

dashboard & widgets unit testing

Fix ResizeObserver mock

common component unit tests

fix some merge errors

Update app-frame.component.spec.ts

Create page-header.component.spec.ts

input component unit tests

FilterableDropdownComponent unit testing

and found minor errors

update taskservice unit tests

Edit dialogs unit tests

Create date-dropdown.component.spec.ts

Remove selectors from guard tests

confirm dialog component tests

app frame component test

Miscellaneous component tests

Update document-list-view.service.spec.ts

directives unit tests

Remove unused resizeobserver mock

guard unit tests

Update query-params.spec.ts

try to fix flaky playwright

filter rules utils & testing

Interceptor unit tests

Pipes unit testing

Utils unit tests

Update upload-documents.service.spec.ts

consumer status service tests

Update setup-jest.ts

Create document-list-view.service.spec.ts

Update app-routing.module.ts

145 files changed:
src-ui/jest.config.js
src-ui/package-lock.json
src-ui/package.json
src-ui/setup-jest.ts
src-ui/src/app/app-routing.module.ts
src-ui/src/app/app.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/app-frame/app-frame.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/app-frame/app-frame.component.ts
src-ui/src/app/components/common/clearable-badge/clearable-badge.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/date-dropdown/date-dropdown.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts
src-ui/src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts
src-ui/src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/abstract-input.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/check/check.component.html
src-ui/src/app/components/common/input/check/check.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/check/check.component.ts
src-ui/src/app/components/common/input/color/color.component.html
src-ui/src/app/components/common/input/color/color.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/date/date.component.html
src-ui/src/app/components/common/input/date/date.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/number/number.component.html
src-ui/src/app/components/common/input/number/number.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/password/password.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/permissions/permissions-form/permissions-form.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/select/select.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/tags/tags.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/tags/tags.component.ts
src-ui/src/app/components/common/input/text/text.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/page-header/page-header.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/select-dialog/select-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/tag/tag.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/toasts/toasts.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/toasts/toasts.component.ts
src-ui/src/app/components/dashboard/dashboard.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts
src-ui/src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-asn/document-asn.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-detail/document-detail.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts
src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/document-list.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/document-list.component.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-notes/document-notes.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-notes/document-notes.component.ts
src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/document-type-list/document-type-list.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/logs/logs.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/management-list/management-list.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/management-list/management-list.component.ts
src-ui/src/app/components/manage/settings/settings.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/settings/settings.component.ts
src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/tasks/tasks.component.html
src-ui/src/app/components/manage/tasks/tasks.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/tasks/tasks.component.ts
src-ui/src/app/components/not-found/not-found.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/with-permissions/with-permissions.component.spec.ts [new file with mode: 0644]
src-ui/src/app/data/filter-rule.ts
src-ui/src/app/directives/if-object-permissions.directive.spec.ts [new file with mode: 0644]
src-ui/src/app/directives/if-owner.directive.spec.ts [new file with mode: 0644]
src-ui/src/app/directives/if-permissions.directive.spec.ts [new file with mode: 0644]
src-ui/src/app/directives/sortable.directive.spec.ts [new file with mode: 0644]
src-ui/src/app/guards/dirty-doc.guard.spec.ts [new file with mode: 0644]
src-ui/src/app/guards/dirty-form.guard.spec.ts [new file with mode: 0644]
src-ui/src/app/guards/dirty-saved-view.guard.spec.ts [new file with mode: 0644]
src-ui/src/app/guards/permissions.guard.spec.ts [new file with mode: 0644]
src-ui/src/app/interceptors/api-version.interceptor.spec.ts [new file with mode: 0644]
src-ui/src/app/interceptors/csrf.interceptor.spec.ts
src-ui/src/app/interceptors/csrf.interceptor.ts
src-ui/src/app/pipes/custom-date.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/document-title.pipe.spec.ts
src-ui/src/app/pipes/file-size.pipe.spec.ts
src-ui/src/app/pipes/filter.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/safehtml.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/safeurl.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/username.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/yes-no.pipe.spec.ts
src-ui/src/app/services/consumer-status.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/document-list-view.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/document-list-view.service.ts
src-ui/src/app/services/open-documents.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/permissions.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/permissions.service.ts
src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/abstract-paperless-service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/abstract-paperless-service.ts
src-ui/src/app/services/rest/correspondent.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/document-notes.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/document-type.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/document.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/group.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/log.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/mail-account.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/mail-rule.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/remote-version.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/saved-view.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/search.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/storage-path.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/tag.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/user.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/settings.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/settings.service.ts
src-ui/src/app/services/tasks.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/tasks.service.ts
src-ui/src/app/services/toast.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/upload-documents.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/upload-documents.service.ts
src-ui/src/app/utils/color.spec.ts [new file with mode: 0644]
src-ui/src/app/utils/filter-rules.spec.ts [new file with mode: 0644]
src-ui/src/app/utils/filter-rules.ts [new file with mode: 0644]
src-ui/src/app/utils/ngb-date-parser-formatter.spec.ts [new file with mode: 0644]
src-ui/src/app/utils/ngb-iso-date-adapter.spec.ts [new file with mode: 0644]
src-ui/src/app/utils/query-params.spec.ts [new file with mode: 0644]
src-ui/src/app/utils/query-params.ts

index 23de7b188ca5d8706adccb87c5a855c4ff206157..14892964ec4ea6331c7a7976174478c7825ff34f 100644 (file)
@@ -1,8 +1,14 @@
 module.exports = {
-  moduleNameMapper: {
-    '@core/(.*)': '<rootDir>/src/app/core/$1',
-  },
   preset: 'jest-preset-angular',
   setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
-  testPathIgnorePatterns: ['/node_modules/', '/cypress/'],
+  testPathIgnorePatterns: [
+    '/node_modules/',
+    '/e2e/',
+    'abstract-name-filter-service',
+    'abstract-paperless-service',
+  ],
+  transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
+  moduleNameMapper: {
+    '^src/(.*)': '<rootDir>/src/$1',
+  },
 }
index dff374c0323732f0121961a744b7cc4a7a51af8c..71d98cc159ad8dbd3f55799cd62ee224ff0f84b1 100644 (file)
@@ -53,6 +53,7 @@
         "jest": "28.1.3",
         "jest-environment-jsdom": "^29.5.0",
         "jest-preset-angular": "^12.2.6",
+        "jest-websocket-mock": "^2.4.0",
         "ts-node": "~10.9.1",
         "typescript": "~4.9.5",
         "wait-on": "^7.0.1"
         "node": ">=8"
       }
     },
+    "node_modules/jest-websocket-mock": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/jest-websocket-mock/-/jest-websocket-mock-2.4.0.tgz",
+      "integrity": "sha512-AOwyuRw6fgROXHxMOiTDl1/T4dh3fV4jDquha5N0csS/PNp742HeTZWPAuKppVRSQ8s3fUGgJHoyZT9JDO0hMA==",
+      "dev": true,
+      "dependencies": {
+        "jest-diff": "^28.0.2",
+        "mock-socket": "^9.1.0"
+      }
+    },
     "node_modules/jest-worker": {
       "version": "28.1.3",
       "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz",
         "node": ">=10"
       }
     },
+    "node_modules/mock-socket": {
+      "version": "9.2.1",
+      "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.2.1.tgz",
+      "integrity": "sha512-aw9F9T9G2zpGipLLhSNh6ZpgUyUl4frcVmRN08uE1NWPWg43Wx6+sGPDbQ7E5iFZZDJW5b5bypMeAEHqTbIFag==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
index 20578394c25a15a0c9ee42d64e9be8b208ec31e3..0da23eb5cc308a00bbaf72cbcb4d5558f0bb04bb 100644 (file)
@@ -5,7 +5,7 @@
     "ng": "ng",
     "start": "ng serve",
     "build": "ng build",
-    "test": "ng test",
+    "test": "ng test --no-watch --coverage",
     "lint": "ng lint"
   },
   "private": true,
@@ -55,6 +55,7 @@
     "jest": "28.1.3",
     "jest-environment-jsdom": "^29.5.0",
     "jest-preset-angular": "^12.2.6",
+    "jest-websocket-mock": "^2.4.0",
     "ts-node": "~10.9.1",
     "typescript": "~4.9.5",
     "wait-on": "^7.0.1"
index 876946e2bef7c0c258138e37eb822e64b08205af..628c2a74cf76a732cf1cd84af38e4dcc39ac851f 100644 (file)
@@ -1,4 +1,59 @@
 import { jest } from '@jest/globals'
+if (process.env.NODE_ENV === 'test') {
+  require('jest-preset-angular/setup-jest')
+}
+import '@angular/localize/init'
+import { TextEncoder, TextDecoder } from 'util'
+global.TextEncoder = TextEncoder
+global.TextDecoder = TextDecoder
+
+import { registerLocaleData } from '@angular/common'
+import localeAr from '@angular/common/locales/ar'
+import localeBe from '@angular/common/locales/be'
+import localeCa from '@angular/common/locales/ca'
+import localeCs from '@angular/common/locales/cs'
+import localeDa from '@angular/common/locales/da'
+import localeDe from '@angular/common/locales/de'
+import localeEnGb from '@angular/common/locales/en-GB'
+import localeEs from '@angular/common/locales/es'
+import localeFi from '@angular/common/locales/fi'
+import localeFr from '@angular/common/locales/fr'
+import localeIt from '@angular/common/locales/it'
+import localeLb from '@angular/common/locales/lb'
+import localeNl from '@angular/common/locales/nl'
+import localePl from '@angular/common/locales/pl'
+import localePt from '@angular/common/locales/pt'
+import localeRo from '@angular/common/locales/ro'
+import localeRu from '@angular/common/locales/ru'
+import localeSl from '@angular/common/locales/sl'
+import localeSr from '@angular/common/locales/sr'
+import localeSv from '@angular/common/locales/sv'
+import localeTr from '@angular/common/locales/tr'
+import localeZh from '@angular/common/locales/zh'
+
+registerLocaleData(localeAr)
+registerLocaleData(localeBe)
+registerLocaleData(localeCa)
+registerLocaleData(localeCs)
+registerLocaleData(localeDa)
+registerLocaleData(localeDe)
+registerLocaleData(localeEnGb)
+registerLocaleData(localeEs)
+registerLocaleData(localeFi)
+registerLocaleData(localeFr)
+registerLocaleData(localeIt)
+registerLocaleData(localeLb)
+registerLocaleData(localeNl)
+registerLocaleData(localePl)
+registerLocaleData(localePt, 'pt-BR')
+registerLocaleData(localePt, 'pt-PT')
+registerLocaleData(localeRo)
+registerLocaleData(localeRu)
+registerLocaleData(localeSl)
+registerLocaleData(localeSr)
+registerLocaleData(localeSv)
+registerLocaleData(localeTr)
+registerLocaleData(localeZh)
 
 /* global mocks for jsdom */
 const mock = () => {
@@ -17,6 +72,8 @@ Object.defineProperty(window, 'getComputedStyle', {
   value: () => ['-webkit-appearance'],
 })
 
+Object.defineProperty(window, 'ResizeObserver', { value: mock() })
+
 Object.defineProperty(document.body.style, 'transform', {
   value: () => {
     return {
index 4d12ee4f3926d599cc3b8ec072a5ca7fcc47bbe2..d839274f80080c6ad9f389c51cd1ed6f1c1882fd 100644 (file)
@@ -22,7 +22,7 @@ import {
   PermissionType,
 } from './services/permissions.service'
 
-const routes: Routes = [
+export const routes: Routes = [
   { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
   {
     path: '',
diff --git a/src-ui/src/app/app.component.spec.ts b/src-ui/src/app/app.component.spec.ts
new file mode 100644 (file)
index 0000000..57c0e1a
--- /dev/null
@@ -0,0 +1,182 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import {
+  ComponentFixture,
+  TestBed,
+  discardPeriodicTasks,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { By } from '@angular/platform-browser'
+import { Router } from '@angular/router'
+import { RouterTestingModule } from '@angular/router/testing'
+import { NgxFileDropModule } from 'ngx-file-drop'
+import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
+import { Subject } from 'rxjs'
+import { routes } from './app-routing.module'
+import { AppComponent } from './app.component'
+import { ToastsComponent } from './components/common/toasts/toasts.component'
+import {
+  ConsumerStatusService,
+  FileStatus,
+} from './services/consumer-status.service'
+import { PermissionsService } from './services/permissions.service'
+import { ToastService, Toast } from './services/toast.service'
+import { UploadDocumentsService } from './services/upload-documents.service'
+import { SettingsService } from './services/settings.service'
+
+describe('AppComponent', () => {
+  let component: AppComponent
+  let fixture: ComponentFixture<AppComponent>
+  let tourService: TourService
+  let consumerStatusService: ConsumerStatusService
+  let permissionsService: PermissionsService
+  let toastService: ToastService
+  let router: Router
+  let settingsService: SettingsService
+  let uploadDocumentsService: UploadDocumentsService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [AppComponent, ToastsComponent],
+      providers: [],
+      imports: [
+        HttpClientTestingModule,
+        TourNgBootstrapModule,
+        RouterTestingModule.withRoutes(routes),
+        NgxFileDropModule,
+      ],
+    }).compileComponents()
+
+    tourService = TestBed.inject(TourService)
+    consumerStatusService = TestBed.inject(ConsumerStatusService)
+    permissionsService = TestBed.inject(PermissionsService)
+    settingsService = TestBed.inject(SettingsService)
+    toastService = TestBed.inject(ToastService)
+    router = TestBed.inject(Router)
+    uploadDocumentsService = TestBed.inject(UploadDocumentsService)
+    fixture = TestBed.createComponent(AppComponent)
+    component = fixture.componentInstance
+  })
+
+  it('should initialize the tour service & toggle class on body for styling', fakeAsync(() => {
+    jest.spyOn(console, 'warn').mockImplementation(() => {})
+    fixture.detectChanges()
+    const tourSpy = jest.spyOn(tourService, 'initialize')
+    component.ngOnInit()
+    expect(tourSpy).toHaveBeenCalled()
+    tourService.start()
+    expect(document.body.classList).toContain('tour-active')
+    tourService.end()
+    tick(500)
+    expect(document.body.classList).not.toContain('tour-active')
+  }))
+
+  it('should display toast on document consumed with link if user has access', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    let toast: Toast
+    toastService.getToasts().subscribe((toasts) => (toast = toasts[0]))
+    const toastSpy = jest.spyOn(toastService, 'show')
+    const fileStatusSubject = new Subject<FileStatus>()
+    jest
+      .spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
+      .mockReturnValue(fileStatusSubject)
+    component.ngOnInit()
+    fileStatusSubject.next(new FileStatus())
+    expect(toastSpy).toHaveBeenCalled()
+    expect(toast.action).not.toBeUndefined()
+  })
+
+  it('should display toast on document consumed without link if user does not have access', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
+    let toast: Toast
+    toastService.getToasts().subscribe((toasts) => (toast = toasts[0]))
+    const toastSpy = jest.spyOn(toastService, 'show')
+    const fileStatusSubject = new Subject<FileStatus>()
+    jest
+      .spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
+      .mockReturnValue(fileStatusSubject)
+    component.ngOnInit()
+    fileStatusSubject.next(new FileStatus())
+    expect(toastSpy).toHaveBeenCalled()
+    expect(toast.action).toBeUndefined()
+  })
+
+  it('should display toast on document added', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    const toastSpy = jest.spyOn(toastService, 'show')
+    const fileStatusSubject = new Subject<FileStatus>()
+    jest
+      .spyOn(consumerStatusService, 'onDocumentDetected')
+      .mockReturnValue(fileStatusSubject)
+    component.ngOnInit()
+    fileStatusSubject.next(new FileStatus())
+    expect(toastSpy).toHaveBeenCalled()
+  })
+
+  it('should suppress dashboard notifications if set', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest.spyOn(settingsService, 'get').mockReturnValue(true)
+    jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
+    const toastSpy = jest.spyOn(toastService, 'show')
+    const fileStatusSubject = new Subject<FileStatus>()
+    jest
+      .spyOn(consumerStatusService, 'onDocumentDetected')
+      .mockReturnValue(fileStatusSubject)
+    component.ngOnInit()
+    fileStatusSubject.next(new FileStatus())
+    expect(toastSpy).not.toHaveBeenCalled()
+  })
+
+  it('should display toast on document failed', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    const fileStatusSubject = new Subject<FileStatus>()
+    jest
+      .spyOn(consumerStatusService, 'onDocumentConsumptionFailed')
+      .mockReturnValue(fileStatusSubject)
+    component.ngOnInit()
+    fileStatusSubject.next(new FileStatus())
+    expect(toastSpy).toHaveBeenCalled()
+  })
+
+  it('should disable drag-drop if on dashboard', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/dashboard')
+    expect(component.dragDropEnabled).toBeFalsy()
+    jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/documents')
+    expect(component.dragDropEnabled).toBeTruthy()
+  })
+
+  it('should enable drag-drop if user has permissions', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    expect(component.dragDropEnabled).toBeTruthy()
+  })
+
+  it('should disable drag-drop if user does not have permissions', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
+    expect(component.dragDropEnabled).toBeFalsy()
+  })
+
+  it('should support drag drop', fakeAsync(() => {
+    expect(component.fileIsOver).toBeFalsy()
+    component.fileOver()
+    tick(1)
+    fixture.detectChanges()
+    expect(component.fileIsOver).toBeTruthy()
+    const dropzone = fixture.debugElement.query(
+      By.css('.global-dropzone-overlay')
+    )
+    expect(dropzone).not.toBeNull()
+    component.fileLeave()
+    tick(700)
+    fixture.detectChanges()
+    expect(dropzone.classes['hide']).toBeTruthy()
+    // drop
+    const toastSpy = jest.spyOn(toastService, 'show')
+    const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
+    component.dropped([])
+    tick(3000)
+    expect(toastSpy).toHaveBeenCalled()
+    expect(uploadSpy).toHaveBeenCalled()
+  }))
+})
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts
new file mode 100644 (file)
index 0000000..c685413
--- /dev/null
@@ -0,0 +1,272 @@
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { AppFrameComponent } from './app-frame.component'
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { BrowserModule } from '@angular/platform-browser'
+import { RouterTestingModule } from '@angular/router/testing'
+import { SettingsService } from 'src/app/services/settings.service'
+import { SavedViewService } from 'src/app/services/rest/saved-view.service'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
+import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { of } from 'rxjs'
+import { ToastService } from 'src/app/services/toast.service'
+import { environment } from 'src/environments/environment'
+import { OpenDocumentsService } from 'src/app/services/open-documents.service'
+import { ActivatedRoute, Router } from '@angular/router'
+import { DocumentDetailComponent } from '../document-detail/document-detail.component'
+import { SearchService } from 'src/app/services/rest/search.service'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
+import { routes } from 'src/app/app-routing.module'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+
+const document = { id: 2, title: 'Hello world' }
+
+describe('AppFrameComponent', () => {
+  let component: AppFrameComponent
+  let fixture: ComponentFixture<AppFrameComponent>
+  let httpTestingController: HttpTestingController
+  let settingsService: SettingsService
+  let permissionsService: PermissionsService
+  let remoteVersionService: RemoteVersionService
+  let toastService: ToastService
+  let openDocumentsService: OpenDocumentsService
+  let searchService: SearchService
+  let documentListViewService: DocumentListViewService
+  let router: Router
+  let savedViewSpy
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [AppFrameComponent, IfPermissionsDirective],
+      imports: [
+        HttpClientTestingModule,
+        BrowserModule,
+        RouterTestingModule.withRoutes(routes),
+        NgbModule,
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+      providers: [
+        SettingsService,
+        SavedViewService,
+        PermissionsService,
+        RemoteVersionService,
+        IfPermissionsDirective,
+        ToastService,
+        OpenDocumentsService,
+        SearchService,
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            firstChild: {
+              component: DocumentDetailComponent,
+            },
+            snapshot: {
+              firstChild: {
+                component: DocumentDetailComponent,
+                params: {
+                  id: document.id,
+                },
+              },
+            },
+          },
+        },
+        PermissionsGuard,
+      ],
+    }).compileComponents()
+
+    settingsService = TestBed.inject(SettingsService)
+    const savedViewService = TestBed.inject(SavedViewService)
+    permissionsService = TestBed.inject(PermissionsService)
+    remoteVersionService = TestBed.inject(RemoteVersionService)
+    toastService = TestBed.inject(ToastService)
+    openDocumentsService = TestBed.inject(OpenDocumentsService)
+    searchService = TestBed.inject(SearchService)
+    documentListViewService = TestBed.inject(DocumentListViewService)
+    router = TestBed.inject(Router)
+
+    jest
+      .spyOn(settingsService, 'displayName', 'get')
+      .mockReturnValue('Hello World')
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+
+    savedViewSpy = jest.spyOn(savedViewService, 'initialize')
+
+    fixture = TestBed.createComponent(AppFrameComponent)
+    component = fixture.componentInstance
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+
+    fixture.detectChanges()
+  })
+
+  it('should initialize the saved view service', () => {
+    expect(savedViewSpy).toHaveBeenCalled()
+  })
+
+  it('should check for update if enabled', () => {
+    const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
+    updateCheckSpy.mockImplementation(() => {
+      return of({
+        version: 'v100.0',
+        update_available: true,
+      })
+    })
+    settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, true)
+    component.ngOnInit()
+    expect(updateCheckSpy).toHaveBeenCalled()
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent).toContain('Update available')
+  })
+
+  it('should check not for update if disabled', () => {
+    const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
+    settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, false)
+    component.ngOnInit()
+    fixture.detectChanges()
+    expect(updateCheckSpy).not.toHaveBeenCalled()
+    expect(fixture.nativeElement.textContent).not.toContain('Update available')
+  })
+
+  it('should check for update if was disabled and then enabled', () => {
+    const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
+    settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, false)
+    component.setUpdateChecking(true)
+    fixture.detectChanges()
+    expect(updateCheckSpy).toHaveBeenCalled()
+  })
+
+  it('should show error on toggle update checking if store settings fails', () => {
+    jest.spyOn(console, 'warn').mockImplementation(() => {})
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, false)
+    component.setUpdateChecking(true)
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}ui_settings/`)
+      .flush('error', {
+        status: 500,
+        statusText: 'error',
+      })
+    expect(toastSpy).toHaveBeenCalled()
+  })
+
+  it('should support toggling slim sidebar and saving', fakeAsync(() => {
+    const saveSettingSpy = jest.spyOn(settingsService, 'set')
+    expect(component.slimSidebarEnabled).toBeFalsy()
+    expect(component.slimSidebarAnimating).toBeFalsy()
+    component.toggleSlimSidebar()
+    expect(component.slimSidebarAnimating).toBeTruthy()
+    tick(200)
+    expect(component.slimSidebarAnimating).toBeFalsy()
+    expect(component.slimSidebarEnabled).toBeTruthy()
+    expect(saveSettingSpy).toHaveBeenCalledWith(
+      SETTINGS_KEYS.SLIM_SIDEBAR,
+      true
+    )
+  }))
+
+  it('should show error on toggle slim sidebar if store settings fails', () => {
+    jest.spyOn(console, 'warn').mockImplementation(() => {})
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    component.toggleSlimSidebar()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}ui_settings/`)
+      .flush('error', {
+        status: 500,
+        statusText: 'error',
+      })
+    expect(toastSpy).toHaveBeenCalled()
+  })
+
+  it('should support collapsable menu', () => {
+    const button: HTMLButtonElement = (
+      fixture.nativeElement as HTMLDivElement
+    ).querySelector('button[data-toggle=collapse]')
+    button.dispatchEvent(new MouseEvent('click'))
+    expect(component.isMenuCollapsed).toBeFalsy()
+    component.closeMenu()
+    expect(component.isMenuCollapsed).toBeTruthy()
+  })
+
+  it('should support close document & navigate on close current doc', () => {
+    const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
+    closeSpy.mockReturnValue(of(true))
+    const routerSpy = jest.spyOn(router, 'navigate')
+    component.closeDocument(document)
+    expect(closeSpy).toHaveBeenCalledWith(document)
+    expect(routerSpy).toHaveBeenCalled()
+  })
+
+  it('should support close all documents & navigate on close current doc', () => {
+    const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
+    closeAllSpy.mockReturnValue(of(true))
+    const routerSpy = jest.spyOn(router, 'navigate')
+    component.closeAll()
+    expect(closeAllSpy).toHaveBeenCalled()
+    expect(routerSpy).toHaveBeenCalled()
+  })
+
+  it('should close all documents on logout', () => {
+    const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
+    component.onLogout()
+    expect(closeAllSpy).toHaveBeenCalled()
+  })
+
+  it('should warn before close if dirty documents', () => {
+    jest.spyOn(openDocumentsService, 'hasDirty').mockReturnValue(true)
+    expect(component.canDeactivate()).toBeFalsy()
+  })
+
+  it('should call autocomplete endpoint on input', fakeAsync(() => {
+    const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
+    component.searchAutoComplete(of('hello')).subscribe()
+    tick(250)
+    expect(autocompleteSpy).toHaveBeenCalled()
+
+    component.searchAutoComplete(of('hello world 1')).subscribe()
+    tick(250)
+    expect(autocompleteSpy).toHaveBeenCalled()
+  }))
+
+  it('should support reset search field', () => {
+    const resetSpy = jest.spyOn(component, 'resetSearchField')
+    const input = (fixture.nativeElement as HTMLDivElement).querySelector(
+      'input'
+    ) as HTMLInputElement
+    input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
+    expect(resetSpy).toHaveBeenCalled()
+  })
+
+  it('should support choosing a search item', () => {
+    expect(component.searchField.value).toEqual('')
+    component.itemSelected({ item: 'hello', preventDefault: () => true })
+    expect(component.searchField.value).toEqual('hello ')
+    component.itemSelected({ item: 'world', preventDefault: () => true })
+    expect(component.searchField.value).toEqual('hello world ')
+  })
+
+  it('should navigate via quickFilter on search', () => {
+    const str = 'hello world '
+    component.searchField.patchValue(str)
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    component.search()
+    expect(qfSpy).toHaveBeenCalledWith([
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: str.trim(),
+      },
+    ])
+  })
+})
index cff4366da1ed4ac029146307078638d63dce4bb0..930b74de31458dc7a5ca8c5efdc53012fe82ed37 100644 (file)
@@ -53,7 +53,7 @@ export class AppFrameComponent
     public settingsService: SettingsService,
     public tasksService: TasksService,
     private readonly toastService: ToastService,
-    private permissionsService: PermissionsService
+    permissionsService: PermissionsService
   ) {
     super()
 
@@ -75,7 +75,7 @@ export class AppFrameComponent
   }
 
   versionString = `${environment.appTitle} ${environment.version}`
-  appRemoteVersion
+  appRemoteVersion: AppRemoteVersion
 
   isMenuCollapsed: boolean = true
 
@@ -103,7 +103,7 @@ export class AppFrameComponent
           this.toastService.showError(
             $localize`An error occurred while saving settings.`
           )
-          console.log(error)
+          console.warn(error)
         },
       })
   }
@@ -236,7 +236,7 @@ export class AppFrameComponent
           this.toastService.showError(
             $localize`An error occurred while saving update checking settings.`
           )
-          console.log(error)
+          console.warn(error)
         },
       })
     if (enable) {
diff --git a/src-ui/src/app/components/common/clearable-badge/clearable-badge.component.spec.ts b/src-ui/src/app/components/common/clearable-badge/clearable-badge.component.spec.ts
new file mode 100644 (file)
index 0000000..90c3f60
--- /dev/null
@@ -0,0 +1,43 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { ClearableBadgeComponent } from './clearable-badge.component'
+
+describe('ClearableBadgeComponent', () => {
+  let component: ClearableBadgeComponent
+  let fixture: ComponentFixture<ClearableBadgeComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [ClearableBadgeComponent],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(ClearableBadgeComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support selected', () => {
+    component.selected = true
+    expect(component.active).toBeTruthy()
+  })
+
+  it('should support numbered', () => {
+    component.number = 3
+    fixture.detectChanges()
+    expect(component.active).toBeTruthy()
+    expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
+  })
+
+  it('should support selected', () => {
+    let clearedResult
+    component.selected = true
+    fixture.detectChanges()
+    component.cleared.subscribe((clear) => {
+      clearedResult = clear
+    })
+    fixture.nativeElement
+      .querySelectorAll('button')[0]
+      .dispatchEvent(new MouseEvent('click'))
+    expect(clearedResult).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..e2c16db
--- /dev/null
@@ -0,0 +1,99 @@
+import {
+  ComponentFixture,
+  TestBed,
+  discardPeriodicTasks,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { ConfirmDialogComponent } from './confirm-dialog.component'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { Subject } from 'rxjs'
+
+describe('ConfirmDialogComponent', () => {
+  let component: ConfirmDialogComponent
+  let modal: NgbActiveModal
+  let fixture: ComponentFixture<ConfirmDialogComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [ConfirmDialogComponent, SafeHtmlPipe],
+      providers: [NgbActiveModal, SafeHtmlPipe],
+      imports: [],
+    }).compileComponents()
+
+    modal = TestBed.inject(NgbActiveModal)
+
+    fixture = TestBed.createComponent(ConfirmDialogComponent)
+    component = fixture.componentInstance
+    component.title = 'Confirm delete'
+    component.messageBold = 'Do you really want to delete document file.pdf?'
+    component.message =
+      'The files for this document will be deleted permanently. This operation cannot be undone.'
+    component.btnClass = 'btn-danger'
+    component.btnCaption = 'Delete document'
+
+    fixture.detectChanges()
+  })
+
+  it('should support alternative', () => {
+    let alternativeClickedResult
+    let alternativeSubjectResult
+    component.alternativeClicked.subscribe((result) => {
+      alternativeClickedResult = true
+    })
+    component.alternative()
+    // with subject
+    const subject = new Subject<boolean>()
+    component.alternativeSubject = subject
+    subject.asObservable().subscribe((result) => {
+      alternativeSubjectResult = result
+    })
+    component.alternative()
+    expect(alternativeClickedResult).toBeTruthy()
+    expect(alternativeSubjectResult).toBeTruthy()
+  })
+
+  it('should support confirm', () => {
+    let confirmClickedResult
+    let confirmSubjectResult
+    component.confirmClicked.subscribe((result) => {
+      confirmClickedResult = true
+    })
+    component.confirm()
+    // with subject
+    const subject = new Subject<boolean>()
+    component.confirmSubject = subject
+    subject.asObservable().subscribe((result) => {
+      confirmSubjectResult = result
+    })
+    component.confirm()
+    expect(confirmClickedResult).toBeTruthy()
+    expect(confirmSubjectResult).toBeTruthy()
+  })
+
+  it('should support cancel & close modal', () => {
+    let confirmSubjectResult
+    const closeModalSpy = jest.spyOn(modal, 'close')
+    component.cancel()
+    const subject = new Subject<boolean>()
+    component.confirmSubject = subject
+    subject.asObservable().subscribe((result) => {
+      confirmSubjectResult = result
+    })
+    component.cancel()
+    // with subject
+    expect(closeModalSpy).toHaveBeenCalled()
+    expect(confirmSubjectResult).toBeFalsy()
+  })
+
+  it('should support delay confirm', fakeAsync(() => {
+    component.confirmButtonEnabled = false
+    component.delayConfirm(1)
+    expect(component.confirmButtonEnabled).toBeFalsy()
+    tick(1500)
+    fixture.detectChanges()
+    expect(component.confirmButtonEnabled).toBeTruthy()
+    discardPeriodicTasks()
+  }))
+})
diff --git a/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.spec.ts b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.spec.ts
new file mode 100644 (file)
index 0000000..b98b713
--- /dev/null
@@ -0,0 +1,141 @@
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+let fixture: ComponentFixture<DateDropdownComponent>
+import {
+  DateDropdownComponent,
+  DateSelection,
+  RelativeDate,
+} from './date-dropdown.component'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { SettingsService } from 'src/app/services/settings.service'
+import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { DatePipe } from '@angular/common'
+
+describe('DateDropdownComponent', () => {
+  let component: DateDropdownComponent
+  let httpTestingController: HttpTestingController
+  let settingsService: SettingsService
+  let settingsSpy
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DateDropdownComponent,
+        ClearableBadgeComponent,
+        CustomDatePipe,
+      ],
+      providers: [SettingsService, CustomDatePipe, DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        NgbModule,
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+    }).compileComponents()
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    settingsService = TestBed.inject(SettingsService)
+    settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
+
+    fixture = TestBed.createComponent(DateDropdownComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should use a localized date placeholder', () => {
+    expect(component.datePlaceHolder).toEqual('mm/dd/yyyy')
+    expect(settingsSpy).toHaveBeenCalled()
+  })
+
+  it('should support date input, emit change', fakeAsync(() => {
+    let result: string
+    component.dateAfterChange.subscribe((date) => (result = date))
+    const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
+    input.value = '5/30/2023'
+    input.dispatchEvent(new Event('change'))
+    tick(500)
+    expect(result).not.toBeNull()
+  }))
+
+  it('should support date select, emit datesSet change', fakeAsync(() => {
+    let result: DateSelection
+    component.datesSet.subscribe((date) => (result = date))
+    const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
+    input.value = '5/30/2023'
+    input.dispatchEvent(new Event('dateSelect'))
+    tick(500)
+    expect(result).not.toBeNull()
+  }))
+
+  it('should support relative dates', fakeAsync(() => {
+    let result: DateSelection
+    component.datesSet.subscribe((date) => (result = date))
+    component.setRelativeDate(null)
+    component.setRelativeDate(RelativeDate.LAST_7_DAYS)
+    tick(500)
+    expect(result).toEqual({
+      after: null,
+      before: null,
+      relativeDateID: RelativeDate.LAST_7_DAYS,
+    })
+  }))
+
+  it('should support report if active', () => {
+    component.relativeDate = RelativeDate.LAST_7_DAYS
+    expect(component.isActive).toBeTruthy()
+    component.relativeDate = null
+    component.dateAfter = '2023-05-30'
+    expect(component.isActive).toBeTruthy()
+    component.dateAfter = null
+    component.dateBefore = '2023-05-30'
+    expect(component.isActive).toBeTruthy()
+    component.dateBefore = null
+    expect(component.isActive).toBeFalsy()
+  })
+
+  it('should support reset', () => {
+    component.dateAfter = '2023-05-30'
+    component.reset()
+    expect(component.dateAfter).toBeNull()
+  })
+
+  it('should support clearAfter', () => {
+    component.dateAfter = '2023-05-30'
+    component.clearAfter()
+    expect(component.dateAfter).toBeNull()
+  })
+
+  it('should support clearBefore', () => {
+    component.dateBefore = '2023-05-30'
+    component.clearBefore()
+    expect(component.dateBefore).toBeNull()
+  })
+
+  it('should limit keyboard events', () => {
+    const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
+    let event: KeyboardEvent = new KeyboardEvent('keypress', {
+      key: '9',
+    })
+    let eventSpy = jest.spyOn(event, 'preventDefault')
+    input.dispatchEvent(event)
+    expect(eventSpy).not.toHaveBeenCalled()
+
+    event = new KeyboardEvent('keypress', {
+      key: '{',
+    })
+    eventSpy = jest.spyOn(event, 'preventDefault')
+    input.dispatchEvent(event)
+    expect(eventSpy).toHaveBeenCalled()
+  })
+})
index 423bf4a6845bd489b2b882ef0452186bf377e7b4..b1493a7d3beb0f6611f09936b252471dc50cc149 100644 (file)
@@ -1,4 +1,3 @@
-import { formatDate } from '@angular/common'
 import {
   Component,
   EventEmitter,
diff --git a/src-ui/src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..843725b
--- /dev/null
@@ -0,0 +1,55 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { CorrespondentEditDialogComponent } from './correspondent-edit-dialog.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+
+describe('CorrespondentEditDialogComponent', () => {
+  let component: CorrespondentEditDialogComponent
+  let fixture: ComponentFixture<CorrespondentEditDialogComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        CorrespondentEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        PermissionsFormComponent,
+      ],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..676073a
--- /dev/null
@@ -0,0 +1,55 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { DocumentTypeEditDialogComponent } from './document-type-edit-dialog.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+
+describe('DocumentTypeEditDialogComponent', () => {
+  let component: DocumentTypeEditDialogComponent
+  let fixture: ComponentFixture<DocumentTypeEditDialogComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DocumentTypeEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        PermissionsFormComponent,
+      ],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..18816d0
--- /dev/null
@@ -0,0 +1,234 @@
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { Component } from '@angular/core'
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import {
+  FormGroup,
+  FormControl,
+  FormsModule,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+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 { EditDialogComponent, EditDialogMode } from './edit-dialog.component'
+import {
+  DEFAULT_MATCHING_ALGORITHM,
+  MATCH_ALL,
+  MATCH_AUTO,
+  MATCH_NONE,
+} from 'src/app/data/matching-model'
+import { of } from 'rxjs'
+import { environment } from 'src/environments/environment'
+
+@Component({
+  template: `
+    <div>
+      <h4 class="modal-title" id="modal-basic-title">{{ getTitle() }}</h4>
+    </div>
+  `,
+})
+class TestComponent extends EditDialogComponent<PaperlessTag> {
+  constructor(
+    service: TagService,
+    activeModal: NgbActiveModal,
+    userService: UserService,
+    settingsService: SettingsService
+  ) {
+    super(service, activeModal, userService, settingsService)
+  }
+
+  getForm(): FormGroup<any> {
+    return new FormGroup({
+      name: new FormControl(''),
+      color: new FormControl(''),
+      is_inbox_tag: new FormControl(false),
+      permissions_form: new FormControl(null),
+      matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
+    })
+  }
+}
+
+const currentUser = {
+  id: 99,
+  username: 'user99',
+}
+
+const permissions = {
+  view: {
+    users: [11],
+    groups: [],
+  },
+  change: {
+    users: [],
+    groups: [2],
+  },
+}
+
+const tag = {
+  id: 1,
+  name: 'Tag 1',
+  color: '#fff000',
+  is_inbox_tag: false,
+  matching_algorithm: MATCH_AUTO,
+  owner: 10,
+  permissions,
+}
+
+describe('EditDialogComponent', () => {
+  let component: TestComponent
+  let fixture: ComponentFixture<TestComponent>
+  let tagService: TagService
+  let activeModal: NgbActiveModal
+  let httpTestingController: HttpTestingController
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [TestComponent],
+      providers: [
+        NgbActiveModal,
+        {
+          provide: UserService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 13,
+                    username: 'user1',
+                  },
+                ],
+              }),
+          },
+        },
+        {
+          provide: SettingsService,
+          useValue: {
+            currentUser,
+          },
+        },
+        TagService,
+      ],
+      imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    tagService = TestBed.inject(TagService)
+    activeModal = TestBed.inject(NgbActiveModal)
+    httpTestingController = TestBed.inject(HttpTestingController)
+
+    fixture = TestBed.createComponent(TestComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should interpolate object permissions', () => {
+    component.object = tag
+    component.dialogMode = EditDialogMode.EDIT
+    component.ngOnInit()
+
+    expect(component.objectForm.get('permissions_form').value).toEqual({
+      owner: tag.owner,
+      set_permissions: permissions,
+    })
+  })
+
+  it('should delay close enabled', fakeAsync(() => {
+    expect(component.closeEnabled).toBeFalsy()
+    component.ngOnInit()
+    tick(100)
+    expect(component.closeEnabled).toBeTruthy()
+  }))
+
+  it('should set default owner when in create mode', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    component.ngOnInit()
+    expect(component.objectForm.get('permissions_form').value.owner).toEqual(
+      currentUser.id
+    )
+    // cover optional chaining
+    component.objectForm.removeControl('permissions_form')
+    component.ngOnInit()
+  })
+
+  it('should detect if pattern required', () => {
+    expect(component.patternRequired).toBeFalsy()
+    component.objectForm.get('matching_algorithm').setValue(MATCH_AUTO)
+    expect(component.patternRequired).toBeFalsy()
+    component.objectForm.get('matching_algorithm').setValue(MATCH_NONE)
+    expect(component.patternRequired).toBeFalsy()
+    component.objectForm.get('matching_algorithm').setValue(MATCH_ALL)
+    expect(component.patternRequired).toBeTruthy()
+    // coverage
+    component.objectForm = null
+    expect(component.patternRequired).toBeTruthy()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+    // coverage
+    component.dialogMode = null
+    fixture.detectChanges()
+  })
+
+  it('should close on cancel', () => {
+    const closeSpy = jest.spyOn(activeModal, 'close')
+    component.cancel()
+    expect(closeSpy).toHaveBeenCalled()
+  })
+
+  it('should update an object on save in edit mode', () => {
+    const updateSpy = jest.spyOn(tagService, 'update')
+    component.dialogMode = EditDialogMode.EDIT
+    component.save()
+    expect(updateSpy).toHaveBeenCalled()
+  })
+
+  it('should create an object on save in edit mode', () => {
+    const createSpy = jest.spyOn(tagService, 'create')
+    component.dialogMode = EditDialogMode.CREATE
+    component.save()
+    expect(createSpy).toHaveBeenCalled()
+  })
+
+  it('should close on successful save', () => {
+    const closeSpy = jest.spyOn(activeModal, 'close')
+    const successSpy = jest.spyOn(component.succeeded, 'emit')
+    component.save()
+    httpTestingController.expectOne(`${environment.apiBaseUrl}tags/`).flush({})
+    expect(closeSpy).toHaveBeenCalled()
+    expect(successSpy).toHaveBeenCalled()
+  })
+
+  it('should not close on failed save', () => {
+    const closeSpy = jest.spyOn(activeModal, 'close')
+    const failedSpy = jest.spyOn(component.failed, 'next')
+    component.save()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}tags/`)
+      .flush('error', {
+        status: 500,
+        statusText: 'error',
+      })
+    expect(closeSpy).not.toHaveBeenCalled()
+    expect(failedSpy).toHaveBeenCalled()
+    expect(component.error).toEqual('error')
+  })
+})
index 07693ce5a66ceaf39c7af1486cb69268b8625870..8924a0ac8ed58d4dcc6ded37914984cd3e8e1ca9 100644 (file)
@@ -15,6 +15,11 @@ import { UserService } from 'src/app/services/rest/user.service'
 import { PermissionsFormObject } from '../input/permissions/permissions-form/permissions-form.component'
 import { SettingsService } from 'src/app/services/settings.service'
 
+export enum EditDialogMode {
+  CREATE = 0,
+  EDIT = 1,
+}
+
 @Directive()
 export abstract class EditDialogComponent<
   T extends ObjectWithPermissions | ObjectWithId
@@ -30,7 +35,7 @@ export abstract class EditDialogComponent<
   users: PaperlessUser[]
 
   @Input()
-  dialogMode: string = 'create'
+  dialogMode: EditDialogMode = EditDialogMode.CREATE
 
   @Input()
   object: T
@@ -71,7 +76,7 @@ export abstract class EditDialogComponent<
 
     this.userService.listAll().subscribe((r) => {
       this.users = r.results
-      if (this.dialogMode === 'create') {
+      if (this.dialogMode === EditDialogMode.CREATE) {
         this.objectForm.get('permissions_form')?.setValue({
           owner: this.settingsService.currentUser.id,
         })
@@ -87,15 +92,11 @@ export abstract class EditDialogComponent<
     return $localize`Edit item`
   }
 
-  getSaveErrorMessage(error: string) {
-    return $localize`Could not save element: ${error}`
-  }
-
   getTitle() {
     switch (this.dialogMode) {
-      case 'create':
+      case EditDialogMode.CREATE:
         return this.getCreateTitle()
-      case 'edit':
+      case EditDialogMode.EDIT:
         return this.getEditTitle()
       default:
         break
@@ -127,10 +128,10 @@ export abstract class EditDialogComponent<
     var newObject = Object.assign(Object.assign({}, this.object), formValues)
     var serverResponse: Observable<T>
     switch (this.dialogMode) {
-      case 'create':
+      case EditDialogMode.CREATE:
         serverResponse = this.service.create(newObject)
         break
-      case 'edit':
+      case EditDialogMode.EDIT:
         serverResponse = this.service.update(newObject)
       default:
         break
diff --git a/src-ui/src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..e762bbd
--- /dev/null
@@ -0,0 +1,57 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+import { GroupEditDialogComponent } from './group-edit-dialog.component'
+import { PermissionsSelectComponent } from '../../permissions-select/permissions-select.component'
+
+describe('GroupEditDialogComponent', () => {
+  let component: GroupEditDialogComponent
+  let fixture: ComponentFixture<GroupEditDialogComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        GroupEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        PermissionsFormComponent,
+        PermissionsSelectComponent,
+      ],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(GroupEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..93fa7f0
--- /dev/null
@@ -0,0 +1,117 @@
+import {
+  ComponentFixture,
+  TestBed,
+  discardPeriodicTasks,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+import { MailAccountEditDialogComponent } from './mail-account-edit-dialog.component'
+import { PasswordComponent } from '../../input/password/password.component'
+import { CheckComponent } from '../../input/check/check.component'
+import { IMAPSecurity } from 'src/app/data/paperless-mail-account'
+import { environment } from 'src/environments/environment'
+
+describe('MailAccountEditDialogComponent', () => {
+  let component: MailAccountEditDialogComponent
+  let fixture: ComponentFixture<MailAccountEditDialogComponent>
+  let httpController: HttpTestingController
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        MailAccountEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        CheckComponent,
+        PermissionsFormComponent,
+        PasswordComponent,
+      ],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    httpController = TestBed.inject(HttpTestingController)
+
+    fixture = TestBed.createComponent(MailAccountEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+
+  it('should support test mail account and show appropriate expiring alert', fakeAsync(() => {
+    component.object = {
+      name: 'example',
+      imap_server: 'imap.example.com',
+      username: 'user',
+      password: 'pass',
+      imap_port: 443,
+      imap_security: IMAPSecurity.SSL,
+      is_token: false,
+    }
+
+    // success
+    component.test()
+    httpController
+      .expectOne(`${environment.apiBaseUrl}mail_accounts/test/`)
+      .flush({ success: true })
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent).toContain(
+      'Successfully connected'
+    )
+    tick(6000)
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent).not.toContain(
+      'Successfully connected'
+    )
+
+    // not success
+    component.test()
+    httpController
+      .expectOne(`${environment.apiBaseUrl}mail_accounts/test/`)
+      .flush({ success: false })
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent).toContain('Unable to connect')
+
+    // error
+    component.test()
+    httpController
+      .expectOne(`${environment.apiBaseUrl}mail_accounts/test/`)
+      .flush({}, { status: 500, statusText: 'error' })
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent).toContain('Unable to connect')
+    tick(6000)
+  }))
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..5d08900
--- /dev/null
@@ -0,0 +1,113 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+import { MailRuleEditDialogComponent } from './mail-rule-edit-dialog.component'
+import { NumberComponent } from '../../input/number/number.component'
+import { TagsComponent } from '../../input/tags/tags.component'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { MailAccountService } from 'src/app/services/rest/mail-account.service'
+import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
+import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
+import { of } from 'rxjs'
+import {
+  MailAction,
+  MailMetadataCorrespondentOption,
+} from 'src/app/data/paperless-mail-rule'
+
+describe('MailRuleEditDialogComponent', () => {
+  let component: MailRuleEditDialogComponent
+  let fixture: ComponentFixture<MailRuleEditDialogComponent>
+  let accountService: MailAccountService
+  let correspondentService: CorrespondentService
+  let documentTypeService: DocumentTypeService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        MailRuleEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        PermissionsFormComponent,
+        NumberComponent,
+        TagsComponent,
+        SafeHtmlPipe,
+      ],
+      providers: [
+        NgbActiveModal,
+        {
+          provide: MailAccountService,
+          useValue: {
+            listAll: () => of([]),
+          },
+        },
+        {
+          provide: CorrespondentService,
+          useValue: {
+            listAll: () => of([]),
+          },
+        },
+        {
+          provide: DocumentTypeService,
+          useValue: {
+            listAll: () => of([]),
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(MailRuleEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+
+  it('should support optional fields', () => {
+    expect(component.showCorrespondentField).toBeFalsy()
+    component.objectForm
+      .get('assign_correspondent_from')
+      .setValue(MailMetadataCorrespondentOption.FromCustom)
+    expect(component.showCorrespondentField).toBeTruthy()
+
+    expect(component.showActionParamField).toBeFalsy()
+    component.objectForm.get('action').setValue(MailAction.Move)
+    expect(component.showActionParamField).toBeTruthy()
+    component.objectForm.get('action').setValue('')
+    expect(component.showActionParamField).toBeFalsy()
+    component.objectForm.get('action').setValue(MailAction.Tag)
+    expect(component.showActionParamField).toBeTruthy()
+
+    // coverage of optional chaining
+    component.objectForm = null
+    expect(component.showCorrespondentField).toBeFalsy()
+    expect(component.showActionParamField).toBeFalsy()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..f440927
--- /dev/null
@@ -0,0 +1,57 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+
+describe('StoragePathEditDialogComponent', () => {
+  let component: StoragePathEditDialogComponent
+  let fixture: ComponentFixture<StoragePathEditDialogComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        StoragePathEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        PermissionsFormComponent,
+        SafeHtmlPipe,
+      ],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(StoragePathEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..73f3dd9
--- /dev/null
@@ -0,0 +1,59 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+import { TagEditDialogComponent } from './tag-edit-dialog.component'
+import { ColorComponent } from '../../input/color/color.component'
+import { CheckComponent } from '../../input/check/check.component'
+
+describe('TagEditDialogComponent', () => {
+  let component: TagEditDialogComponent
+  let fixture: ComponentFixture<TagEditDialogComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        TagEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        PermissionsFormComponent,
+        ColorComponent,
+        CheckComponent,
+      ],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(TagEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..1418c74
--- /dev/null
@@ -0,0 +1,115 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { EditDialogMode } from '../edit-dialog.component'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SelectComponent } from '../../input/select/select.component'
+import {
+  AbstractControl,
+  FormsModule,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { TextComponent } from '../../input/text/text.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
+import { UserEditDialogComponent } from './user-edit-dialog.component'
+import { PasswordComponent } from '../../input/password/password.component'
+import { PermissionsSelectComponent } from '../../permissions-select/permissions-select.component'
+import { GroupService } from 'src/app/services/rest/group.service'
+import { of } from 'rxjs'
+
+describe('UserEditDialogComponent', () => {
+  let component: UserEditDialogComponent
+  let fixture: ComponentFixture<UserEditDialogComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        UserEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        PasswordComponent,
+        PermissionsFormComponent,
+        PermissionsSelectComponent,
+      ],
+      providers: [
+        NgbActiveModal,
+        {
+          provide: GroupService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 1,
+                    permissions: ['dummy_perms'],
+                  },
+                ],
+              }),
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(UserEditDialogComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+
+  it('should disable user permissions select on toggle superuser', () => {
+    const control: AbstractControl =
+      component.objectForm.get('user_permissions')
+    expect(control.disabled).toBeFalsy()
+    component.objectForm.get('is_superuser').setValue(true)
+    component.onToggleSuperUser()
+    expect(control.disabled).toBeTruthy()
+  })
+
+  it('should update inherited permissions', () => {
+    component.objectForm.get('groups').setValue(null)
+    expect(component.inheritedPermissions).toEqual([])
+    component.objectForm.get('groups').setValue([1])
+    expect(component.inheritedPermissions).toEqual(['dummy_perms'])
+    component.objectForm.get('groups').setValue([2])
+    expect(component.inheritedPermissions).toEqual([])
+  })
+
+  it('should detect whether password was changed in form on save', () => {
+    component.objectForm.get('password').setValue(null)
+    component.save()
+    expect(component.passwordIsSet).toBeFalsy()
+
+    // unchanged pw
+    component.objectForm.get('password').setValue('*******')
+    component.save()
+    expect(component.passwordIsSet).toBeFalsy()
+
+    // unchanged pw
+    component.objectForm.get('password').setValue('helloworld')
+    component.save()
+    expect(component.passwordIsSet).toBeTruthy()
+  })
+})
index cef3690d184730ba488e981007df92fdbd2b31ed..4ee05f3b247b0342fc8ed95a0aaccb98e3c7de24 100644 (file)
@@ -34,7 +34,7 @@
       <div *ngIf="selectionModel.items" class="items" #buttonItems>
         <ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText; let i = index">
           <app-toggleable-dropdown-button
-            *ngIf="allowSelectNone || item.id" [item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i)" [disabled]="disabled">
+            *ngIf="allowSelectNone || item.id" [item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
           </app-toggleable-dropdown-button>
         </ng-container>
       </div>
diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
new file mode 100644 (file)
index 0000000..42adfdb
--- /dev/null
@@ -0,0 +1,487 @@
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import {
+  ChangedItems,
+  FilterableDropdownComponent,
+  FilterableDropdownSelectionModel,
+  Intersection,
+  LogicalOperator,
+} from './filterable-dropdown.component'
+import { FilterPipe } from 'src/app/pipes/filter.pipe'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+import {
+  DEFAULT_MATCHING_ALGORITHM,
+  MATCH_ALL,
+} from 'src/app/data/matching-model'
+import {
+  ToggleableDropdownButtonComponent,
+  ToggleableItemState,
+} from './toggleable-dropdown-button/toggleable-dropdown-button.component'
+import { TagComponent } from '../tag/tag.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
+
+const items: PaperlessTag[] = [
+  {
+    id: 1,
+    name: 'Tag1',
+    is_inbox_tag: false,
+    matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
+  },
+  {
+    id: 2,
+    name: 'Tag2',
+    is_inbox_tag: true,
+    matching_algorithm: MATCH_ALL,
+    match: 'str',
+  },
+]
+
+const nullItem = {
+  id: null,
+  name: 'Not assigned',
+}
+
+let selectionModel: FilterableDropdownSelectionModel
+
+describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
+  let component: FilterableDropdownComponent
+  let fixture: ComponentFixture<FilterableDropdownComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        FilterableDropdownComponent,
+        FilterPipe,
+        ToggleableDropdownButtonComponent,
+        TagComponent,
+        ClearableBadgeComponent,
+      ],
+      providers: [FilterPipe],
+      imports: [NgbModule, FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(FilterableDropdownComponent)
+    component = fixture.componentInstance
+    selectionModel = new FilterableDropdownSelectionModel()
+  })
+
+  it('should sanitize title', () => {
+    expect(component.name).toBeNull()
+    component.title = 'Foo Bar'
+    expect(component.name).toEqual('foo_bar')
+  })
+
+  it('should support reset', () => {
+    component.items = items
+    component.selectionModel = selectionModel
+    selectionModel.set(items[0].id, ToggleableItemState.Selected)
+    expect(selectionModel.getSelectedItems()).toHaveLength(1)
+    expect(selectionModel.isDirty()).toBeTruthy()
+    component.reset()
+    expect(selectionModel.getSelectedItems()).toHaveLength(0)
+    expect(selectionModel.isDirty()).toBeFalsy()
+  })
+
+  it('should report document counts', () => {
+    component.documentCounts = [
+      {
+        id: items[0].id,
+        document_count: 12,
+      },
+    ]
+    expect(component.getUpdatedDocumentCount(items[0].id)).toEqual(12)
+    expect(component.getUpdatedDocumentCount(items[1].id)).toBeUndefined() // coverate of optional chaining
+  })
+
+  it('should emit change when items selected', () => {
+    component.items = items
+    component.selectionModel = selectionModel
+    let newModel: FilterableDropdownSelectionModel
+    component.selectionModelChange.subscribe((model) => (newModel = model))
+    expect(newModel).toBeUndefined()
+
+    selectionModel.set(items[0].id, ToggleableItemState.Selected)
+    expect(selectionModel.isDirty()).toBeTruthy()
+    expect(newModel.getSelectedItems()).toEqual([items[0]])
+    expect(newModel.getExcludedItems()).toEqual([])
+
+    selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
+    expect(newModel.getSelectedItems()).toEqual([])
+
+    expect(component.items).toEqual([nullItem, ...items])
+  })
+
+  it('should emit change when items excluded', () => {
+    component.items = items
+    component.selectionModel = selectionModel
+    let newModel: FilterableDropdownSelectionModel
+    component.selectionModelChange.subscribe((model) => (newModel = model))
+    expect(newModel).toBeUndefined()
+    selectionModel.toggle(items[0].id)
+    expect(newModel.getSelectedItems()).toEqual([items[0]])
+  })
+
+  it('should emit change when items excluded', () => {
+    component.items = items
+    component.selectionModel = selectionModel
+    let newModel: FilterableDropdownSelectionModel
+    component.selectionModelChange.subscribe((model) => (newModel = model))
+
+    selectionModel.set(items[0].id, ToggleableItemState.Excluded)
+    expect(newModel.getSelectedItems()).toEqual([])
+    expect(newModel.getExcludedItems()).toEqual([items[0]])
+
+    selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
+    expect(newModel.getSelectedItems()).toEqual([])
+    expect(newModel.getExcludedItems()).toEqual([])
+  })
+
+  it('should exclude items when excluded and not editing', () => {
+    component.items = items
+    component.manyToOne = true
+    component.selectionModel = selectionModel
+    selectionModel.set(items[0].id, ToggleableItemState.Selected)
+    component.excludeClicked(items[0].id)
+    expect(selectionModel.getSelectedItems()).toEqual([])
+    expect(selectionModel.getExcludedItems()).toEqual([items[0]])
+  })
+
+  it('should toggle when items excluded and editing', () => {
+    component.items = items
+    component.manyToOne = true
+    component.editing = true
+    component.selectionModel = selectionModel
+    selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
+    component.excludeClicked(items[0].id)
+    expect(selectionModel.getSelectedItems()).toEqual([items[0]])
+    expect(selectionModel.getExcludedItems()).toEqual([])
+  })
+
+  it('should hide count for item if adding will increase size of set', () => {
+    component.items = items
+    component.manyToOne = true
+    component.selectionModel = selectionModel
+    expect(component.hideCount(items[0])).toBeFalsy()
+    selectionModel.logicalOperator = LogicalOperator.Or
+    expect(component.hideCount(items[0])).toBeTruthy()
+  })
+
+  it('should enforce single select when editing', () => {
+    component.editing = true
+    component.items = items
+    component.selectionModel = selectionModel
+    let newModel: FilterableDropdownSelectionModel
+    component.selectionModelChange.subscribe((model) => (newModel = model))
+
+    expect(selectionModel.singleSelect).toEqual(true)
+    selectionModel.toggle(items[0].id)
+    selectionModel.toggle(items[1].id)
+    expect(newModel.getSelectedItems()).toEqual([items[1]])
+  })
+
+  it('should support manyToOne selecting', () => {
+    component.items = items
+    selectionModel.manyToOne = false
+    component.selectionModel = selectionModel
+    component.manyToOne = true
+    expect(component.manyToOne).toBeTruthy()
+    let newModel: FilterableDropdownSelectionModel
+    component.selectionModelChange.subscribe((model) => (newModel = model))
+
+    expect(selectionModel.singleSelect).toEqual(false)
+    selectionModel.toggle(items[0].id)
+    selectionModel.toggle(items[1].id)
+    expect(newModel.getSelectedItems()).toEqual([items[0], items[1]])
+  })
+
+  it('should dynamically enable / disable modifier toggle', () => {
+    component.items = items
+    component.selectionModel = selectionModel
+    expect(component.modifierToggleEnabled).toBeTruthy()
+    selectionModel.toggle(null)
+    expect(component.modifierToggleEnabled).toBeFalsy()
+    component.manyToOne = true
+    expect(component.modifierToggleEnabled).toBeFalsy()
+    selectionModel.toggle(items[0].id)
+    selectionModel.toggle(items[1].id)
+    expect(component.modifierToggleEnabled).toBeTruthy()
+  })
+
+  it('should apply changes and close when apply button clicked', () => {
+    component.items = items
+    component.editing = true
+    component.selectionModel = selectionModel
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    selectionModel.toggle(items[0].id)
+    fixture.detectChanges()
+    expect(component.modelIsDirty).toBeTruthy()
+    let applyResult: ChangedItems
+    const closeSpy = jest.spyOn(component.dropdown, 'close')
+    component.apply.subscribe((result) => (applyResult = result))
+    const applyButton = Array.from(
+      (fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
+    ).find((b) => b.textContent.includes('Apply'))
+    applyButton.dispatchEvent(new MouseEvent('click'))
+    expect(closeSpy).toHaveBeenCalled()
+    expect(applyResult).toEqual({ itemsToAdd: [items[0]], itemsToRemove: [] })
+  })
+
+  it('should apply on close if enabled', () => {
+    component.items = items
+    component.editing = true
+    component.applyOnClose = true
+    component.selectionModel = selectionModel
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    selectionModel.toggle(items[0].id)
+    fixture.detectChanges()
+    expect(component.modelIsDirty).toBeTruthy()
+    let applyResult: ChangedItems
+    component.apply.subscribe((result) => (applyResult = result))
+    component.dropdown.close()
+    expect(applyResult).toEqual({ itemsToAdd: [items[0]], itemsToRemove: [] })
+  })
+
+  it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
+    component.items = items
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+    expect(document.activeElement).toEqual(
+      component.listFilterTextInput.nativeElement
+    )
+    expect(
+      Array.from(
+        (fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
+      ).filter((b) => b.textContent.includes('Tag'))
+    ).toHaveLength(2)
+    component.filterText = 'Tag2'
+    fixture.detectChanges()
+    expect(
+      Array.from(
+        (fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
+      ).filter((b) => b.textContent.includes('Tag'))
+    ).toHaveLength(1)
+    component.dropdown.close()
+    expect(component.filterText).toHaveLength(0)
+  }))
+
+  it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
+    component.items = items
+    expect(component.selectionModel.getSelectedItems()).toEqual([])
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+    component.filterText = 'Tag2'
+    fixture.detectChanges()
+    const closeSpy = jest.spyOn(component.dropdown, 'close')
+    component.listFilterTextInput.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Enter' })
+    )
+    expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
+    tick(300)
+    expect(closeSpy).toHaveBeenCalled()
+  }))
+
+  it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
+    component.items = items
+    component.editing = true
+    let applyResult: ChangedItems
+    component.apply.subscribe((result) => (applyResult = result))
+    expect(component.selectionModel.getSelectedItems()).toEqual([])
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+    component.filterText = 'Tag2'
+    fixture.detectChanges()
+    component.listFilterTextInput.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Enter' })
+    )
+    expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
+    tick(300)
+    expect(applyResult).toEqual({ itemsToAdd: [items[1]], itemsToRemove: [] })
+  }))
+
+  it('should support arrow keyboard navigation', fakeAsync(() => {
+    component.items = items
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+    const filterInputEl: HTMLInputElement =
+      component.listFilterTextInput.nativeElement
+    expect(document.activeElement).toEqual(filterInputEl)
+    const itemButtons = Array.from(
+      (fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
+    ).filter((b) => b.textContent.includes('Tag'))
+    filterInputEl.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+    )
+    expect(document.activeElement).toEqual(itemButtons[0])
+    itemButtons[0].dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+    )
+    expect(document.activeElement).toEqual(itemButtons[1])
+    itemButtons[1].dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
+    )
+    expect(document.activeElement).toEqual(itemButtons[0])
+    itemButtons[0].dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
+    )
+    expect(document.activeElement).toEqual(filterInputEl)
+    filterInputEl.value = 'foo'
+    component.filterText = 'foo'
+
+    // dont move focus if we're traversing the field
+    filterInputEl.selectionStart = 1
+    expect(document.activeElement).toEqual(filterInputEl)
+
+    // now we're at end, so move focus
+    filterInputEl.selectionStart = 3
+    filterInputEl.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+    )
+    expect(document.activeElement).toEqual(itemButtons[0])
+  }))
+
+  it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
+    component.items = items
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+    const filterInputEl: HTMLInputElement =
+      component.listFilterTextInput.nativeElement
+    expect(document.activeElement).toEqual(filterInputEl)
+    const itemButtons = Array.from(
+      (fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
+    ).filter((b) => b.textContent.includes('Tag'))
+    filterInputEl.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
+    )
+    itemButtons[0].focus() // normally handled by browser
+    itemButtons[0].dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
+    )
+    itemButtons[1].focus() // normally handled by browser
+    itemButtons[1].dispatchEvent(
+      new KeyboardEvent('keydown', {
+        key: 'Tab',
+        shiftKey: true,
+        bubbles: true,
+      })
+    )
+    itemButtons[0].focus() // normally handled by browser
+    itemButtons[0].dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+    )
+    expect(document.activeElement).toEqual(itemButtons[1])
+  }))
+
+  it('should support arrow keyboard navigation after click', fakeAsync(() => {
+    component.items = items
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+    const filterInputEl: HTMLInputElement =
+      component.listFilterTextInput.nativeElement
+    expect(document.activeElement).toEqual(filterInputEl)
+    const itemButtons = Array.from(
+      (fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
+    ).filter((b) => b.textContent.includes('Tag'))
+    fixture.nativeElement
+      .querySelector('app-toggleable-dropdown-button')
+      .dispatchEvent(new MouseEvent('click'))
+    itemButtons[0].focus() // normally handled by browser
+    expect(document.activeElement).toEqual(itemButtons[0])
+    itemButtons[0].dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+    )
+    expect(document.activeElement).toEqual(itemButtons[1])
+  }))
+
+  it('should toggle logical operator', fakeAsync(() => {
+    component.items = items
+    component.manyToOne = true
+    selectionModel.set(items[0].id, ToggleableItemState.Selected)
+    selectionModel.set(items[1].id, ToggleableItemState.Selected)
+    component.selectionModel = selectionModel
+    let changedResult: FilterableDropdownSelectionModel
+    component.selectionModelChange.subscribe(
+      (result) => (changedResult = result)
+    )
+
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+
+    expect(component.modifierToggleEnabled).toBeTruthy()
+    const operatorButtons: HTMLInputElement[] = Array.from(
+      (fixture.nativeElement as HTMLDivElement).querySelectorAll('input')
+    ).filter((b) => ['and', 'or'].includes(b.value))
+    expect(operatorButtons[0].checked).toBeTruthy()
+    operatorButtons[1].dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+    expect(selectionModel.logicalOperator).toEqual(LogicalOperator.Or)
+    expect(changedResult.logicalOperator).toEqual(LogicalOperator.Or)
+  }))
+
+  it('should toggle intersection include / exclude', fakeAsync(() => {
+    component.items = items
+    selectionModel.set(items[0].id, ToggleableItemState.Selected)
+    selectionModel.set(items[1].id, ToggleableItemState.Selected)
+    component.selectionModel = selectionModel
+    let changedResult: FilterableDropdownSelectionModel
+    component.selectionModelChange.subscribe(
+      (result) => (changedResult = result)
+    )
+
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+
+    expect(component.modifierToggleEnabled).toBeTruthy()
+    const intersectionButtons: HTMLInputElement[] = Array.from(
+      (fixture.nativeElement as HTMLDivElement).querySelectorAll('input')
+    ).filter((b) => ['include', 'exclude'].includes(b.value))
+    expect(intersectionButtons[0].checked).toBeTruthy()
+    intersectionButtons[1].dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+    expect(selectionModel.intersection).toEqual(Intersection.Exclude)
+    expect(changedResult.intersection).toEqual(Intersection.Exclude)
+    expect(changedResult.getSelectedItems()).toEqual([])
+    expect(changedResult.getExcludedItems()).toEqual(items)
+  }))
+
+  it('FilterableDropdownSelectionModel should sort items by state', () => {
+    component.items = items
+    component.selectionModel = selectionModel
+    selectionModel.toggle(items[1].id)
+    selectionModel.apply()
+    expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
+  })
+})
index 79548aaf8f7a30a2435e2f6e3a65fc71725a60d1..e0c363fdbc5b76320e0007afdb8263704b6650c5 100644 (file)
@@ -96,7 +96,7 @@ export class FilterableDropdownSelectionModel {
   toggle(id: number, fireEvent = true) {
     let state = this.temporarySelectionStates.get(id)
     if (
-      state == null ||
+      state == undefined ||
       (state != ToggleableItemState.Selected &&
         state != ToggleableItemState.Excluded)
     ) {
diff --git a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.spec.ts b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.spec.ts
new file mode 100644 (file)
index 0000000..712c1c9
--- /dev/null
@@ -0,0 +1,79 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  ToggleableDropdownButtonComponent,
+  ToggleableItemState,
+} from './toggleable-dropdown-button.component'
+import { TagComponent } from '../../tag/tag.component'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+
+describe('ToggleableDropdownButtonComponent', () => {
+  let component: ToggleableDropdownButtonComponent
+  let fixture: ComponentFixture<ToggleableDropdownButtonComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [ToggleableDropdownButtonComponent, TagComponent],
+      providers: [],
+      imports: [],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(ToggleableDropdownButtonComponent)
+    component = fixture.componentInstance
+  })
+
+  it('should recognize a tag', () => {
+    component.item = {
+      id: 1,
+      name: 'Test Tag',
+      is_inbox_tag: false,
+    } as PaperlessTag
+
+    fixture.detectChanges()
+    expect(component.isTag).toBeTruthy()
+  })
+
+  it('should report toggled state', () => {
+    expect(component.isChecked()).toBeFalsy()
+    expect(component.isPartiallyChecked()).toBeFalsy()
+    expect(component.isExcluded()).toBeFalsy()
+
+    component.state = ToggleableItemState.Selected
+    expect(component.isChecked()).toBeTruthy()
+    expect(component.isPartiallyChecked()).toBeFalsy()
+    expect(component.isExcluded()).toBeFalsy()
+
+    component.state = ToggleableItemState.PartiallySelected
+    expect(component.isPartiallyChecked()).toBeTruthy()
+    expect(component.isChecked()).toBeFalsy()
+    expect(component.isExcluded()).toBeFalsy()
+
+    component.state = ToggleableItemState.Excluded
+    expect(component.isExcluded()).toBeTruthy()
+    expect(component.isChecked()).toBeFalsy()
+    expect(component.isPartiallyChecked()).toBeFalsy()
+  })
+
+  it('should emit exclude event when selected and then toggled', () => {
+    let excludeResult
+    let toggleResult
+    component.state = ToggleableItemState.Selected
+    component.exclude.subscribe(() => (excludeResult = true))
+    component.toggle.subscribe(() => (toggleResult = true))
+    const button = fixture.nativeElement.querySelector('button')
+    button.dispatchEvent(new MouseEvent('click'))
+    expect(excludeResult).toBeTruthy()
+    expect(toggleResult).toBeFalsy()
+  })
+
+  it('should emit toggle event when not selected and then toggled', () => {
+    let excludeResult
+    let toggleResult
+    component.state = ToggleableItemState.Excluded
+    component.exclude.subscribe(() => (excludeResult = true))
+    component.toggle.subscribe(() => (toggleResult = true))
+    const button = fixture.nativeElement.querySelector('button')
+    button.dispatchEvent(new MouseEvent('click'))
+    expect(excludeResult).toBeFalsy()
+    expect(toggleResult).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/components/common/input/abstract-input.spec.ts b/src-ui/src/app/components/common/input/abstract-input.spec.ts
new file mode 100644 (file)
index 0000000..f3836f9
--- /dev/null
@@ -0,0 +1,55 @@
+import { Component } from '@angular/core'
+import { AbstractInputComponent } from './abstract-input'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+
+@Component({
+  template: `
+    <div>
+      <input
+        #inputField
+        type="text"
+        class="form-control"
+        [class.is-invalid]="error"
+        [id]="inputId"
+        [(ngModel)]="value"
+        (change)="onChange(value)"
+        [disabled]="disabled"
+      />
+    </div>
+  `,
+})
+class TestComponent extends AbstractInputComponent<string> {
+  constructor() {
+    super()
+  }
+}
+
+describe(`AbstractInputComponent`, () => {
+  let component: TestComponent
+  let fixture: ComponentFixture<TestComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [TestComponent],
+      providers: [],
+      imports: [FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(TestComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should assign uuid', () => {
+    component.ngOnInit()
+    expect(component.inputId).not.toBeUndefined()
+  })
+
+  it('should support focus', () => {
+    const focusSpy = jest.spyOn(component.inputField.nativeElement, 'focus')
+    component.focus()
+    expect(focusSpy).toHaveBeenCalled()
+  })
+})
index 62de7d14311069f6e42373b3a8935771fbf651e4..51d5250b43db7322f1d4e5cd7bd85f88e74f498e 100644 (file)
@@ -1,5 +1,5 @@
 <div class="mb-3 form-check">
-  <input type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
+  <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
   <label class="form-check-label" [for]="inputId">{{title}}</label>
   <div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
 </div>
diff --git a/src-ui/src/app/components/common/input/check/check.component.spec.ts b/src-ui/src/app/components/common/input/check/check.component.spec.ts
new file mode 100644 (file)
index 0000000..7008c48
--- /dev/null
@@ -0,0 +1,39 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { CheckComponent } from './check.component'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+
+describe('CheckComponent', () => {
+  let component: CheckComponent
+  let fixture: ComponentFixture<CheckComponent>
+  let input: HTMLInputElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [CheckComponent],
+      providers: [],
+      imports: [FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(CheckComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  it('should support use of checkbox', () => {
+    input.checked = true
+    input.dispatchEvent(new Event('change'))
+    fixture.detectChanges()
+    expect(component.value).toBeTruthy()
+
+    input.checked = false
+    input.dispatchEvent(new Event('change'))
+    fixture.detectChanges()
+    expect(component.value).toBeFalsy()
+  })
+})
index f4a6e527ebafb29a9a579df8c8c42f600b19ad2d..1815421424f95212628b204abb0849ec6ab9a2a1 100644 (file)
@@ -1,6 +1,5 @@
-import { Component, forwardRef, Input, OnInit } from '@angular/core'
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { v4 as uuidv4 } from 'uuid'
+import { Component, forwardRef } from '@angular/core'
+import { NG_VALUE_ACCESSOR } from '@angular/forms'
 import { AbstractInputComponent } from '../abstract-input'
 
 @Component({
index 08cbf0babdbcdfbd833946a7653e63142077836e..f905f5e7c20d4ac1d4e98bcac26f07aca027e4b0 100644 (file)
@@ -11,7 +11,7 @@
 
       </ng-template>
 
-      <input class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
+      <input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
 
       <button class="btn btn-outline-secondary" type="button" (click)="randomize()">
         <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-dice-5" viewBox="0 0 16 16">
diff --git a/src-ui/src/app/components/common/input/color/color.component.spec.ts b/src-ui/src/app/components/common/input/color/color.component.spec.ts
new file mode 100644 (file)
index 0000000..3e7b0da
--- /dev/null
@@ -0,0 +1,72 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { ColorComponent } from './color.component'
+import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
+import { ColorSliderModule } from 'ngx-color/slider'
+
+describe('ColorComponent', () => {
+  let component: ColorComponent
+  let fixture: ComponentFixture<ColorComponent>
+  let input: HTMLInputElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [ColorComponent],
+      providers: [],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        NgbPopoverModule,
+        ColorSliderModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(ColorComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  it('should support use of input', () => {
+    input.value = '#ff0000'
+    component.colorChanged(input.value)
+    fixture.detectChanges()
+    expect(component.value).toEqual('#ff0000')
+  })
+
+  it('should set swatch color', () => {
+    const swatch: HTMLSpanElement = fixture.nativeElement.querySelector(
+      'span.input-group-text'
+    )
+    expect(swatch.style.backgroundColor).toEqual('')
+    component.value = '#ff0000'
+    fixture.detectChanges()
+    expect(swatch.style.backgroundColor).toEqual('rgb(255, 0, 0)')
+  })
+
+  it('should show color slider popover', () => {
+    component.value = '#ff0000'
+    input.dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+    expect(
+      fixture.nativeElement.querySelector('ngb-popover-window')
+    ).not.toBeUndefined()
+    expect(
+      fixture.nativeElement.querySelector('color-slider')
+    ).not.toBeUndefined()
+    fixture.nativeElement
+      .querySelector('color-slider')
+      .dispatchEvent(new Event('change'))
+  })
+
+  it('should allow randomize color and update value', () => {
+    expect(component.value).toBeUndefined()
+    component.randomize()
+    expect(component.value).not.toBeUndefined()
+  })
+})
index 38f2bb5a90d8bb73e8942ffd2f901c435b6a33f4..66cdb50920a3d4d1c169b35cd0f5dd154a453055 100644 (file)
@@ -1,7 +1,7 @@
 <div class="mb-3">
   <label class="form-label" [for]="inputId">{{title}}</label>
   <div class="input-group" [class.is-invalid]="error">
-    <input class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
+    <input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
           (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
           name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
     <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
diff --git a/src-ui/src/app/components/common/input/date/date.component.spec.ts b/src-ui/src/app/components/common/input/date/date.component.spec.ts
new file mode 100644 (file)
index 0000000..2b54674
--- /dev/null
@@ -0,0 +1,103 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { DateComponent } from './date.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import {
+  NgbDateParserFormatter,
+  NgbDatepickerModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { RouterTestingModule } from '@angular/router/testing'
+import { LocalizedDateParserFormatter } from 'src/app/utils/ngb-date-parser-formatter'
+
+describe('DateComponent', () => {
+  let component: DateComponent
+  let fixture: ComponentFixture<DateComponent>
+  let input: HTMLInputElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [DateComponent],
+      providers: [
+        {
+          provide: NgbDateParserFormatter,
+          useClass: LocalizedDateParserFormatter,
+        },
+      ],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        HttpClientTestingModule,
+        NgbDatepickerModule,
+        RouterTestingModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(DateComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  it('should support use of input field', () => {
+    input.value = '5/14/20'
+    input.dispatchEvent(new Event('change'))
+    fixture.detectChanges()
+    expect(component.value).toEqual({ day: 14, month: 5, year: 2020 })
+  })
+
+  it('should use localzed placeholder from settings', () => {
+    component.ngOnInit()
+    expect(component.placeholder).toEqual('mm/dd/yyyy')
+  })
+
+  it('should support suggestions', () => {
+    expect(component.value).toBeUndefined()
+    component.suggestions = ['2023-05-31', '2014-05-14']
+    fixture.detectChanges()
+    const suggestionAnchor: HTMLAnchorElement =
+      fixture.nativeElement.querySelector('a')
+    suggestionAnchor.click()
+    expect(component.value).toEqual({ day: 31, month: 5, year: 2023 })
+  })
+
+  it('should limit keyboard events', () => {
+    let event: KeyboardEvent = new KeyboardEvent('keypress', {
+      key: '9',
+    })
+    let eventSpy = jest.spyOn(event, 'preventDefault')
+    input.dispatchEvent(event)
+    expect(eventSpy).not.toHaveBeenCalled()
+
+    event = new KeyboardEvent('keypress', {
+      key: '{',
+    })
+    eventSpy = jest.spyOn(event, 'preventDefault')
+    input.dispatchEvent(event)
+    expect(eventSpy).toHaveBeenCalled()
+  })
+
+  it('should support paste', () => {
+    expect(component.value).toBeUndefined()
+    const date = '5/4/20'
+    const clipboardData = {
+      dropEffect: null,
+      effectAllowed: null,
+      files: null,
+      items: null,
+      types: null,
+      clearData: null,
+      getData: () => date,
+      setData: null,
+      setDragImage: null,
+    }
+    const event = new Event('paste')
+    event['clipboardData'] = clipboardData
+    input.dispatchEvent(event)
+    expect(component.value).toEqual({ day: 4, month: 5, year: 2020 })
+  })
+})
index 85b1012aaae46993f05b024ce8e872d9b47def62..1e1f1237ca661547b64c5158c114f77c3bb2dc42 100644 (file)
@@ -1,7 +1,7 @@
 <div class="mb-3">
   <label class="form-label" [for]="inputId">{{title}}</label>
   <div class="input-group" [class.is-invalid]="error">
-    <input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
+    <input #inputField type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
     <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
   </div>
   <div class="invalid-feedback">
diff --git a/src-ui/src/app/components/common/input/number/number.component.spec.ts b/src-ui/src/app/components/common/input/number/number.component.spec.ts
new file mode 100644 (file)
index 0000000..bf1b40d
--- /dev/null
@@ -0,0 +1,79 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { NumberComponent } from './number.component'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { of } from 'rxjs'
+
+describe('NumberComponent', () => {
+  let component: NumberComponent
+  let fixture: ComponentFixture<NumberComponent>
+  let input: HTMLInputElement
+  let documentService: DocumentService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [NumberComponent],
+      providers: [DocumentService],
+      imports: [FormsModule, ReactiveFormsModule, HttpClientTestingModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(NumberComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    documentService = TestBed.inject(DocumentService)
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  // TODO: why doesnt this work?
+  // it('should support use of input field', () => {
+  //   expect(component.value).toBeUndefined()
+  //   input.stepUp()
+  //   console.log(input.value);
+
+  //   input.dispatchEvent(new Event('change'))
+  //   fixture.detectChanges()
+  //   expect(component.value).toEqual('3')
+  // })
+
+  it('should support +1 ASN', () => {
+    const listAllSpy = jest.spyOn(documentService, 'listFiltered')
+    listAllSpy
+      .mockReturnValueOnce(
+        of({
+          count: 1,
+          all: [1],
+          results: [
+            {
+              id: 1,
+              archive_serial_number: 1000,
+            },
+          ],
+        })
+      )
+      .mockReturnValueOnce(
+        of({
+          count: 0,
+          all: [],
+          results: [],
+        })
+      )
+    expect(component.value).toBeUndefined()
+    component.nextAsn()
+    expect(component.value).toEqual(1001)
+
+    // this time results are empty
+    component.value = undefined
+    component.nextAsn()
+    expect(component.value).toEqual(1)
+
+    component.value = 1002
+    component.nextAsn()
+    expect(component.value).toEqual(1002)
+  })
+})
diff --git a/src-ui/src/app/components/common/input/password/password.component.spec.ts b/src-ui/src/app/components/common/input/password/password.component.spec.ts
new file mode 100644 (file)
index 0000000..80ad853
--- /dev/null
@@ -0,0 +1,36 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { PasswordComponent } from './password.component'
+
+describe('PasswordComponent', () => {
+  let component: PasswordComponent
+  let fixture: ComponentFixture<PasswordComponent>
+  let input: HTMLInputElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [PasswordComponent],
+      providers: [],
+      imports: [FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(PasswordComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  it('should support use of input field', () => {
+    expect(component.value).toBeUndefined()
+    // TODO: why doesnt this work?
+    // input.value = 'foo'
+    // input.dispatchEvent(new Event('change'))
+    // fixture.detectChanges()
+    // expect(component.value).toEqual('foo')
+  })
+})
diff --git a/src-ui/src/app/components/common/input/permissions/permissions-form/permissions-form.component.spec.ts b/src-ui/src/app/components/common/input/permissions/permissions-form/permissions-form.component.spec.ts
new file mode 100644 (file)
index 0000000..bad414a
--- /dev/null
@@ -0,0 +1,66 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { PermissionsFormComponent } from './permissions-form.component'
+import { SelectComponent } from '../../select/select.component'
+import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
+import { PermissionsGroupComponent } from '../permissions-group/permissions-group.component'
+import { PermissionsUserComponent } from '../permissions-user/permissions-user.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { NgSelectModule } from '@ng-select/ng-select'
+
+describe('PermissionsFormComponent', () => {
+  let component: PermissionsFormComponent
+  let fixture: ComponentFixture<PermissionsFormComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        PermissionsFormComponent,
+        SelectComponent,
+        PermissionsGroupComponent,
+        PermissionsUserComponent,
+      ],
+      providers: [],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        NgbAccordionModule,
+        HttpClientTestingModule,
+        NgSelectModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(PermissionsFormComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should support use of select for owner', () => {
+    const changeSpy = jest.spyOn(component, 'onChange')
+    component.ngOnInit()
+    component.users = [
+      {
+        id: 2,
+        username: 'foo',
+      },
+      {
+        id: 3,
+        username: 'bar',
+      },
+    ]
+    component.form.get('owner').patchValue(2)
+    fixture.detectChanges()
+    expect(changeSpy).toHaveBeenCalledWith({
+      owner: 2,
+      set_permissions: {
+        view: { users: [], groups: [] },
+        change: { users: [], groups: [] },
+      },
+    })
+  })
+})
diff --git a/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts b/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts
new file mode 100644 (file)
index 0000000..7de1490
--- /dev/null
@@ -0,0 +1,59 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { PermissionsGroupComponent } from './permissions-group.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { GroupService } from 'src/app/services/rest/group.service'
+import { of } from 'rxjs'
+
+describe('PermissionsGroupComponent', () => {
+  let component: PermissionsGroupComponent
+  let fixture: ComponentFixture<PermissionsGroupComponent>
+  let groupService: GroupService
+  let groupServiceSpy
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [PermissionsGroupComponent],
+      providers: [GroupService],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        HttpClientTestingModule,
+        NgSelectModule,
+      ],
+    }).compileComponents()
+
+    groupService = TestBed.inject(GroupService)
+    groupServiceSpy = jest.spyOn(groupService, 'listAll').mockReturnValue(
+      of({
+        count: 2,
+        all: [2, 3],
+        results: [
+          {
+            id: 2,
+            name: 'Group 2',
+          },
+          {
+            id: 3,
+            name: 'Group 3',
+          },
+        ],
+      })
+    )
+    fixture = TestBed.createComponent(PermissionsGroupComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should get groups, support use of select', () => {
+    component.writeValue({ id: 2, name: 'Group 2' })
+    expect(component.value).toEqual({ id: 2, name: 'Group 2' })
+    expect(groupServiceSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts b/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts
new file mode 100644 (file)
index 0000000..f30c009
--- /dev/null
@@ -0,0 +1,60 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { PermissionsUserComponent } from './permissions-user.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { GroupService } from 'src/app/services/rest/group.service'
+import { of } from 'rxjs'
+import { UserService } from 'src/app/services/rest/user.service'
+
+describe('PermissionsUserComponent', () => {
+  let component: PermissionsUserComponent
+  let fixture: ComponentFixture<PermissionsUserComponent>
+  let userService: UserService
+  let userServiceSpy
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [PermissionsUserComponent],
+      providers: [UserService],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        HttpClientTestingModule,
+        NgSelectModule,
+      ],
+    }).compileComponents()
+
+    userService = TestBed.inject(UserService)
+    userServiceSpy = jest.spyOn(userService, 'listAll').mockReturnValue(
+      of({
+        count: 2,
+        all: [2, 3],
+        results: [
+          {
+            id: 2,
+            name: 'User 2',
+          },
+          {
+            id: 3,
+            name: 'User 3',
+          },
+        ],
+      })
+    )
+    fixture = TestBed.createComponent(PermissionsUserComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should get users, support use of select', () => {
+    component.writeValue({ id: 2, name: 'User 2' })
+    expect(component.value).toEqual({ id: 2, name: 'User 2' })
+    expect(userServiceSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/input/select/select.component.spec.ts b/src-ui/src/app/components/common/input/select/select.component.spec.ts
new file mode 100644 (file)
index 0000000..554175c
--- /dev/null
@@ -0,0 +1,121 @@
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { SelectComponent } from './select.component'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+import {
+  DEFAULT_MATCHING_ALGORITHM,
+  MATCH_ALL,
+} from 'src/app/data/matching-model'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { RouterTestingModule } from '@angular/router/testing'
+
+const items: PaperlessTag[] = [
+  {
+    id: 1,
+    name: 'Tag1',
+    is_inbox_tag: false,
+    matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
+  },
+  {
+    id: 2,
+    name: 'Tag2',
+    is_inbox_tag: true,
+    matching_algorithm: MATCH_ALL,
+    match: 'str',
+  },
+  {
+    id: 10,
+    name: 'Tag10',
+    is_inbox_tag: false,
+    matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
+  },
+]
+
+describe('SelectComponent', () => {
+  let component: SelectComponent
+  let fixture: ComponentFixture<SelectComponent>
+  let input: HTMLInputElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [SelectComponent],
+      providers: [],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        RouterTestingModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(SelectComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should support private items', () => {
+    component.value = 3
+    component.items = items
+    expect(component.items).toContainEqual({
+      id: 3,
+      name: 'Private',
+      private: true,
+    })
+
+    component.checkForPrivateItems([4, 5])
+    expect(component.items).toContainEqual({
+      id: 4,
+      name: 'Private',
+      private: true,
+    })
+    expect(component.items).toContainEqual({
+      id: 5,
+      name: 'Private',
+      private: true,
+    })
+  })
+
+  it('should support suggestions', () => {
+    expect(component.value).toBeUndefined()
+    component.items = items
+    component.suggestions = [1, 2]
+    fixture.detectChanges()
+    const suggestionAnchor: HTMLAnchorElement =
+      fixture.nativeElement.querySelector('a')
+    suggestionAnchor.click()
+    expect(component.value).toEqual(1)
+  })
+
+  it('should support create new and emit the value', () => {
+    expect(component.allowCreateNew).toBeFalsy()
+    component.items = items
+    let createNewVal
+    component.createNew.subscribe((v) => (createNewVal = v))
+    expect(component.allowCreateNew).toBeTruthy()
+    component.onSearch({ term: 'foo' })
+    component.addItem(undefined)
+    expect(createNewVal).toEqual('foo')
+    component.addItem('bar')
+    expect(createNewVal).toEqual('bar')
+    component.onSearch({ term: 'baz' })
+    component.clickNew()
+    expect(createNewVal).toEqual('baz')
+  })
+
+  it('should clear search term on blur after delay', fakeAsync(() => {
+    const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
+    component.onBlur()
+    tick(3000)
+    expect(clearSpy).toHaveBeenCalled()
+  }))
+})
diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
new file mode 100644 (file)
index 0000000..f3ea05d
--- /dev/null
@@ -0,0 +1,140 @@
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { TagsComponent } from './tags.component'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+import {
+  DEFAULT_MATCHING_ALGORITHM,
+  MATCH_ALL,
+} from 'src/app/data/matching-model'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { RouterTestingModule } from '@angular/router/testing'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { of } from 'rxjs'
+import { TagService } from 'src/app/services/rest/tag.service'
+import {
+  NgbModal,
+  NgbModalModule,
+  NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+
+const tags: PaperlessTag[] = [
+  {
+    id: 1,
+    name: 'Tag1',
+    is_inbox_tag: false,
+    matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
+  },
+  {
+    id: 2,
+    name: 'Tag2',
+    is_inbox_tag: true,
+    matching_algorithm: MATCH_ALL,
+    match: 'str',
+  },
+  {
+    id: 10,
+    name: 'Tag10',
+    is_inbox_tag: false,
+    matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
+  },
+]
+
+describe('TagsComponent', () => {
+  let component: TagsComponent
+  let fixture: ComponentFixture<TagsComponent>
+  let input: HTMLInputElement
+  let modalService: NgbModal
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [TagsComponent],
+      providers: [
+        {
+          provide: TagService,
+          useValue: {
+            listAll: () => of(tags),
+          },
+        },
+      ],
+      imports: [
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        RouterTestingModule,
+        HttpClientTestingModule,
+        NgbModalModule,
+      ],
+    }).compileComponents()
+
+    modalService = TestBed.inject(NgbModal)
+    fixture = TestBed.createComponent(TagsComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+
+    window.PointerEvent = MouseEvent as any
+  })
+
+  it('should support suggestions', () => {
+    expect(component.value).toBeUndefined()
+    component.value = []
+    component.tags = tags
+    component.suggestions = [1, 2]
+    fixture.detectChanges()
+    const suggestionAnchor: HTMLAnchorElement =
+      fixture.nativeElement.querySelector('a')
+    suggestionAnchor.click()
+    expect(component.value).toEqual([1])
+  })
+
+  it('should support create new and open a modal', () => {
+    let activeInstances: NgbModalRef[]
+    modalService.activeInstances.subscribe((v) => (activeInstances = v))
+    component.createTag('foo')
+    expect(modalService.hasOpenModals()).toBeTruthy()
+    expect(activeInstances[0].componentInstance.object.name).toEqual('foo')
+  })
+
+  it('should support create new using last search term and open a modal', () => {
+    let activeInstances: NgbModalRef[]
+    modalService.activeInstances.subscribe((v) => (activeInstances = v))
+    component.onSearch({ term: 'bar' })
+    component.createTag()
+    expect(modalService.hasOpenModals()).toBeTruthy()
+    expect(activeInstances[0].componentInstance.object.name).toEqual('bar')
+  })
+
+  it('should clear search term on blur after delay', fakeAsync(() => {
+    const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
+    component.onBlur()
+    tick(3000)
+    expect(clearSpy).toHaveBeenCalled()
+  }))
+
+  it('support remove tags', () => {
+    component.tags = tags
+    component.value = [1, 2]
+    component.removeTag(new PointerEvent('point'), 2)
+    expect(component.value).toEqual([1])
+
+    component.disabled = true
+    component.removeTag(new PointerEvent('point'), 1)
+    expect(component.value).toEqual([1])
+  })
+
+  it('should get tags', () => {
+    expect(component.getTag(2)).toBeNull()
+    component.tags = tags
+    expect(component.getTag(2)).toEqual(tags[1])
+    expect(component.getTag(4)).toBeUndefined()
+  })
+})
index 2b596f6409249764f08e5d8f27fc42647af3f2d4..ea92488f9fcbf77c8df58e7875bf466cc6343362 100644 (file)
@@ -11,6 +11,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { PaperlessTag } from 'src/app/data/paperless-tag'
 import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
 import { TagService } from 'src/app/services/rest/tag.service'
+import { EditDialogMode } from '../../edit-dialog/edit-dialog.component'
 
 @Component({
   providers: [
@@ -105,7 +106,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
     var modal = this.modalService.open(TagEditDialogComponent, {
       backdrop: 'static',
     })
-    modal.componentInstance.dialogMode = 'create'
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
     if (name) modal.componentInstance.object = { name: name }
     else if (this._lastSearchTerm)
       modal.componentInstance.object = { name: this._lastSearchTerm }
diff --git a/src-ui/src/app/components/common/input/text/text.component.spec.ts b/src-ui/src/app/components/common/input/text/text.component.spec.ts
new file mode 100644 (file)
index 0000000..4b0a13b
--- /dev/null
@@ -0,0 +1,36 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { TextComponent } from './text.component'
+
+describe('TextComponent', () => {
+  let component: TextComponent
+  let fixture: ComponentFixture<TextComponent>
+  let input: HTMLInputElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [TextComponent],
+      providers: [],
+      imports: [FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(TextComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  it('should support use of input field', () => {
+    expect(component.value).toBeUndefined()
+    // TODO: why doesnt this work?
+    // input.value = 'foo'
+    // input.dispatchEvent(new Event('change'))
+    // fixture.detectChanges()
+    // expect(component.value).toEqual('foo')
+  })
+})
diff --git a/src-ui/src/app/components/common/page-header/page-header.component.spec.ts b/src-ui/src/app/components/common/page-header/page-header.component.spec.ts
new file mode 100644 (file)
index 0000000..cff3754
--- /dev/null
@@ -0,0 +1,36 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { Title } from '@angular/platform-browser'
+import { PageHeaderComponent } from './page-header.component'
+import { environment } from 'src/environments/environment'
+
+describe('PageHeaderComponent', () => {
+  let component: PageHeaderComponent
+  let fixture: ComponentFixture<PageHeaderComponent>
+  let titleService: Title
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [PageHeaderComponent],
+      providers: [],
+      imports: [],
+    }).compileComponents()
+
+    titleService = TestBed.inject(Title)
+    fixture = TestBed.createComponent(PageHeaderComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should display title + subtitle', () => {
+    component.title = 'Foo'
+    component.subTitle = 'Bar'
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent).toContain('FooBar')
+  })
+
+  it('should set html title', () => {
+    const titleSpy = jest.spyOn(titleService, 'setTitle')
+    component.title = 'Foo Bar'
+    expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
+  })
+})
diff --git a/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..bf9b55c
--- /dev/null
@@ -0,0 +1,90 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { PermissionsDialogComponent } from './permissions-dialog.component'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { UserService } from 'src/app/services/rest/user.service'
+import { of } from 'rxjs'
+import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
+import { SelectComponent } from '../input/select/select.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
+import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
+
+const set_permissions = {
+  owner: 10,
+  set_permissions: {
+    view: {
+      users: [1],
+      groups: [],
+    },
+    edit: {
+      users: [1],
+      groups: [],
+    },
+  },
+}
+
+describe('PermissionsDialogComponent', () => {
+  let component: PermissionsDialogComponent
+  let fixture: ComponentFixture<PermissionsDialogComponent>
+  let modal: NgbActiveModal
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        PermissionsDialogComponent,
+        SafeHtmlPipe,
+        SelectComponent,
+        PermissionsFormComponent,
+        PermissionsUserComponent,
+        PermissionsGroupComponent,
+      ],
+      providers: [
+        NgbActiveModal,
+        {
+          provide: UserService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 1,
+                    username: 'user1',
+                  },
+                  {
+                    id: 10,
+                    username: 'user10',
+                  },
+                ],
+              }),
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        NgSelectModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    modal = TestBed.inject(NgbActiveModal)
+    fixture = TestBed.createComponent(PermissionsDialogComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should return permissions', () => {
+    component.form.get('permissions_form').setValue(set_permissions)
+    expect(component.permissions).toEqual(set_permissions)
+  })
+
+  it('should close modal on cancel', () => {
+    const closeSpy = jest.spyOn(modal, 'close')
+    component.cancelClicked()
+    expect(closeSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.spec.ts b/src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.spec.ts
new file mode 100644 (file)
index 0000000..add538d
--- /dev/null
@@ -0,0 +1,157 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { of } from 'rxjs'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { UserService } from 'src/app/services/rest/user.service'
+import {
+  OwnerFilterType,
+  PermissionsFilterDropdownComponent,
+  PermissionsSelectionModel,
+} from './permissions-filter-dropdown.component'
+import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
+import { SettingsService } from 'src/app/services/settings.service'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+
+const currentUserID = 13
+
+describe('PermissionsFilterDropdownComponent', () => {
+  let component: PermissionsFilterDropdownComponent
+  let fixture: ComponentFixture<PermissionsFilterDropdownComponent>
+  let ownerFilterSetResult: PermissionsSelectionModel
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        PermissionsFilterDropdownComponent,
+        ClearableBadgeComponent,
+        IfPermissionsDirective,
+      ],
+      providers: [
+        {
+          provide: UserService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 1,
+                    username: 'user1',
+                  },
+                  {
+                    id: 10,
+                    username: 'user10',
+                  },
+                ],
+              }),
+          },
+        },
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+          },
+        },
+        {
+          provide: SettingsService,
+          useValue: {
+            currentUser: {
+              id: currentUserID,
+            },
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        NgSelectModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(PermissionsFilterDropdownComponent)
+    component = fixture.componentInstance
+    component.ownerFilterSet.subscribe(
+      (model) => (ownerFilterSetResult = model)
+    )
+    component.selectionModel = new PermissionsSelectionModel()
+
+    fixture.detectChanges()
+  })
+
+  it('should report is active', () => {
+    component.setFilter(OwnerFilterType.NONE)
+    expect(component.isActive).toBeFalsy()
+    component.setFilter(OwnerFilterType.OTHERS)
+    expect(component.isActive).toBeTruthy()
+    component.setFilter(OwnerFilterType.NONE)
+    component.selectionModel.hideUnowned = true
+    expect(component.isActive).toBeTruthy()
+  })
+
+  it('should support reset', () => {
+    component.setFilter(OwnerFilterType.OTHERS)
+    expect(component.selectionModel.ownerFilter).not.toEqual(
+      OwnerFilterType.NONE
+    )
+    component.reset()
+    expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
+  })
+
+  it('should toggle owner filter type when users selected', () => {
+    component.selectionModel.ownerFilter = OwnerFilterType.NONE
+
+    // this would normally be done by select component
+    component.selectionModel.includeUsers = [12]
+    component.onUserSelect()
+    expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
+
+    // this would normally be done by select component
+    component.selectionModel.includeUsers = null
+    component.onUserSelect()
+
+    expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
+  })
+  it('should emit a selection model depending on the type of owner filter set', () => {
+    component.selectionModel.ownerFilter = OwnerFilterType.NONE
+
+    component.setFilter(OwnerFilterType.SELF)
+    expect(ownerFilterSetResult).toEqual({
+      excludeUsers: [],
+      hideUnowned: false,
+      includeUsers: [],
+      ownerFilter: OwnerFilterType.SELF,
+      userID: currentUserID,
+    })
+
+    component.setFilter(OwnerFilterType.NOT_SELF)
+    expect(ownerFilterSetResult).toEqual({
+      excludeUsers: [currentUserID],
+      hideUnowned: false,
+      includeUsers: [],
+      ownerFilter: OwnerFilterType.NOT_SELF,
+      userID: null,
+    })
+
+    component.setFilter(OwnerFilterType.NONE)
+    expect(ownerFilterSetResult).toEqual({
+      excludeUsers: [],
+      hideUnowned: false,
+      includeUsers: [],
+      ownerFilter: OwnerFilterType.NONE,
+      userID: null,
+    })
+
+    component.setFilter(OwnerFilterType.UNOWNED)
+    expect(ownerFilterSetResult).toEqual({
+      excludeUsers: [],
+      hideUnowned: false,
+      includeUsers: [],
+      ownerFilter: OwnerFilterType.UNOWNED,
+      userID: null,
+    })
+  })
+})
diff --git a/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts b/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts
new file mode 100644 (file)
index 0000000..db20535
--- /dev/null
@@ -0,0 +1,96 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { PermissionsSelectComponent } from './permissions-select.component'
+import {
+  FormsModule,
+  NG_VALUE_ACCESSOR,
+  ReactiveFormsModule,
+} from '@angular/forms'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import {
+  PermissionAction,
+  PermissionType,
+} from 'src/app/services/permissions.service'
+import { By } from '@angular/platform-browser'
+
+const permissions = [
+  'add_document',
+  'view_document',
+  'change_document',
+  'delete_document',
+  'change_tag',
+  'view_documenttype',
+]
+
+const inheritedPermissions = ['change_tag', 'view_documenttype']
+
+describe('PermissionsSelectComponent', () => {
+  let component: PermissionsSelectComponent
+  let fixture: ComponentFixture<PermissionsSelectComponent>
+  let permissionsChangeResult: Permissions
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [PermissionsSelectComponent],
+      providers: [],
+      imports: [FormsModule, ReactiveFormsModule, NgbModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(PermissionsSelectComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    component.registerOnChange((r) => (permissionsChangeResult = r))
+    fixture.detectChanges()
+  })
+
+  it('should create controls for all PermissionType and PermissionAction', () => {
+    expect(Object.values(component.form.controls)).toHaveLength(
+      Object.keys(PermissionType).length
+    )
+    for (var type in component.form.controls) {
+      expect(
+        Object.values(component.form.controls[type].controls)
+      ).toHaveLength(Object.keys(PermissionAction).length)
+    }
+    // coverage
+    component.registerOnTouched(() => {})
+    component.setDisabledState(true)
+  })
+
+  it('should allow toggle all on / off', () => {
+    component.ngOnInit()
+    expect(component.typesWithAllActions.values).toHaveLength(0)
+    component.toggleAll({ target: { checked: true } }, 'Tag')
+    expect(component.typesWithAllActions).toContain('Tag')
+    component.toggleAll({ target: { checked: false } }, 'Tag')
+    expect(component.typesWithAllActions.values).toHaveLength(0)
+  })
+
+  it('should update on permissions set', () => {
+    component.ngOnInit()
+    component.writeValue(permissions)
+    expect(permissionsChangeResult).toEqual(permissions)
+    expect(component.typesWithAllActions).toContain('Document')
+  })
+
+  it('should update checkboxes on permissions set', () => {
+    component.ngOnInit()
+    component.writeValue(permissions)
+    fixture.detectChanges()
+    const input1 = fixture.debugElement.query(By.css('input#Document_Add'))
+    expect(input1.nativeElement.checked).toBeTruthy()
+    const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
+    expect(input2.nativeElement.checked).toBeTruthy()
+  })
+
+  it('disable checkboxes when permissions are inherited', () => {
+    component.ngOnInit()
+    component.inheritedPermissions = inheritedPermissions
+    expect(component.isInherited('Document', 'Add')).toBeFalsy()
+    expect(component.isInherited('Document')).toBeFalsy()
+    expect(component.isInherited('Tag', 'Change')).toBeTruthy()
+    const input1 = fixture.debugElement.query(By.css('input#Document_Add'))
+    expect(input1.nativeElement.disabled).toBeFalsy()
+    const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
+    expect(input2.nativeElement.disabled).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/components/common/select-dialog/select-dialog.component.spec.ts b/src-ui/src/app/components/common/select-dialog/select-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..cd4c0dc
--- /dev/null
@@ -0,0 +1,31 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { SelectComponent } from '../input/select/select.component'
+import { SelectDialogComponent } from './select-dialog.component'
+
+describe('SelectDialogComponent', () => {
+  let component: SelectDialogComponent
+  let fixture: ComponentFixture<SelectDialogComponent>
+  let modal: NgbActiveModal
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [SelectDialogComponent, SelectComponent],
+      providers: [NgbActiveModal],
+      imports: [NgSelectModule, FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    modal = TestBed.inject(NgbActiveModal)
+    fixture = TestBed.createComponent(SelectDialogComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should close modal on cancel', () => {
+    const closeSpy = jest.spyOn(modal, 'close')
+    component.cancelClicked()
+    expect(closeSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/tag/tag.component.spec.ts b/src-ui/src/app/components/common/tag/tag.component.spec.ts
new file mode 100644 (file)
index 0000000..300dbde
--- /dev/null
@@ -0,0 +1,51 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { TagComponent } from './tag.component'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+import { By } from '@angular/platform-browser'
+
+const tag: PaperlessTag = {
+  id: 1,
+  color: '#ff0000',
+  name: 'Tag1',
+}
+
+describe('TagComponent', () => {
+  let component: TagComponent
+  let fixture: ComponentFixture<TagComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [TagComponent],
+      providers: [],
+      imports: [],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(TagComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should create tag with background color', () => {
+    component.tag = tag
+    fixture.detectChanges()
+    expect(
+      fixture.debugElement.query(By.css('span')).nativeElement.style
+        .backgroundColor
+    ).toEqual('rgb(255, 0, 0)')
+  })
+
+  it('should handle private tags', () => {
+    expect(
+      fixture.debugElement.query(By.css('span')).nativeElement.textContent
+    ).toEqual('Private')
+  })
+
+  it('should support clickable option', () => {
+    component.tag = tag
+    fixture.detectChanges()
+    expect(fixture.debugElement.query(By.css('a.badge'))).toBeNull()
+    component.clickable = true
+    fixture.detectChanges()
+    expect(fixture.debugElement.query(By.css('a.badge'))).not.toBeNull()
+  })
+})
diff --git a/src-ui/src/app/components/common/toasts/toasts.component.spec.ts b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts
new file mode 100644 (file)
index 0000000..3891347
--- /dev/null
@@ -0,0 +1,94 @@
+import {
+  TestBed,
+  discardPeriodicTasks,
+  fakeAsync,
+  flush,
+} from '@angular/core/testing'
+import { ToastService } from 'src/app/services/toast.service'
+import { ToastsComponent } from './toasts.component'
+import { ComponentFixture } from '@angular/core/testing'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { of } from 'rxjs'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+
+describe('ToastsComponent', () => {
+  let component: ToastsComponent
+  let fixture: ComponentFixture<ToastsComponent>
+  let toastService: ToastService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [ToastsComponent],
+      imports: [HttpClientTestingModule, NgbModule],
+      providers: [
+        {
+          provide: ToastService,
+          useValue: {
+            getToasts: () =>
+              of([
+                {
+                  title: 'Title',
+                  content: 'content',
+                  delay: 5000,
+                },
+                {
+                  title: 'Error',
+                  content: 'Error content',
+                  delay: 5000,
+                  error: new Error('Error message'),
+                },
+              ]),
+          },
+        },
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(ToastsComponent)
+    component = fixture.componentInstance
+
+    toastService = TestBed.inject(ToastService)
+
+    fixture.detectChanges()
+  })
+
+  it('should call getToasts and return toasts', fakeAsync(() => {
+    const spy = jest.spyOn(toastService, 'getToasts').mockReset()
+
+    component.ngOnInit()
+    fixture.detectChanges()
+
+    expect(spy).toHaveBeenCalled()
+    expect(component.toasts).toContainEqual({
+      title: 'Title',
+      content: 'content',
+      delay: 5000,
+    })
+
+    component.ngOnDestroy()
+    flush()
+    discardPeriodicTasks()
+  }))
+
+  it('should show a toast', fakeAsync(() => {
+    component.ngOnInit()
+    fixture.detectChanges()
+
+    expect(fixture.nativeElement.textContent).toContain('Title')
+
+    component.ngOnDestroy()
+    flush()
+    discardPeriodicTasks()
+  }))
+
+  it('should show an error if given with toast', fakeAsync(() => {
+    component.ngOnInit()
+    fixture.detectChanges()
+
+    expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
+    expect(fixture.nativeElement.textContent).toContain('Error message')
+
+    component.ngOnDestroy()
+    flush()
+    discardPeriodicTasks()
+  }))
+})
index ac7a693d6ea6cfea2e3c8b0642ea3d04d9b527d4..9d013c88a0df3fdb472e7b605e7bc1db6f2afe94 100644 (file)
@@ -15,7 +15,7 @@ export class ToastsComponent implements OnInit, OnDestroy {
   toasts: Toast[] = []
 
   ngOnDestroy(): void {
-    this.subscription.unsubscribe()
+    this.subscription?.unsubscribe()
   }
 
   ngOnInit(): void {
diff --git a/src-ui/src/app/components/dashboard/dashboard.component.spec.ts b/src-ui/src/app/components/dashboard/dashboard.component.spec.ts
new file mode 100644 (file)
index 0000000..9115655
--- /dev/null
@@ -0,0 +1,117 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+import { DashboardComponent } from './dashboard.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { SettingsService } from 'src/app/services/settings.service'
+import { StatisticsWidgetComponent } from './widgets/statistics-widget/statistics-widget.component'
+import { PageHeaderComponent } from '../common/page-header/page-header.component'
+import { WidgetFrameComponent } from './widgets/widget-frame/widget-frame.component'
+import { UploadFileWidgetComponent } from './widgets/upload-file-widget/upload-file-widget.component'
+import { SavedViewService } from 'src/app/services/rest/saved-view.service'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { By } from '@angular/platform-browser'
+import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { NgxFileDropModule } from 'ngx-file-drop'
+import { RouterTestingModule } from '@angular/router/testing'
+import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
+
+describe('DashboardComponent', () => {
+  let component: DashboardComponent
+  let fixture: ComponentFixture<DashboardComponent>
+  let settingsService: SettingsService
+  let tourService: TourService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DashboardComponent,
+        StatisticsWidgetComponent,
+        PageHeaderComponent,
+        WidgetFrameComponent,
+        UploadFileWidgetComponent,
+        IfPermissionsDirective,
+        SavedViewWidgetComponent,
+      ],
+      providers: [
+        PermissionsGuard,
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+          },
+        },
+        {
+          provide: SavedViewService,
+          useValue: {
+            dashboardViews: [
+              {
+                id: 1,
+                name: 'saved view 1',
+                show_on_dashboard: true,
+                sort_field: 'added',
+                sort_reverse: true,
+                filter_rules: [],
+              },
+              {
+                id: 2,
+                name: 'saved view 2',
+                show_on_dashboard: true,
+                sort_field: 'created',
+                sort_reverse: true,
+                filter_rules: [],
+              },
+            ],
+          },
+        },
+      ],
+      imports: [
+        NgbAlertModule,
+        HttpClientTestingModule,
+        NgxFileDropModule,
+        RouterTestingModule,
+        TourNgBootstrapModule,
+      ],
+    }).compileComponents()
+
+    settingsService = TestBed.inject(SettingsService)
+    settingsService.currentUser = {
+      first_name: 'Foo',
+      last_name: 'Bar',
+    }
+    tourService = TestBed.inject(TourService)
+    fixture = TestBed.createComponent(DashboardComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should show a welcome message', () => {
+    expect(component.subtitle).toEqual(`Hello Foo, welcome to Paperless-ngx`)
+    settingsService.currentUser = {
+      id: 1,
+    }
+    expect(component.subtitle).toEqual(`Welcome to Paperless-ngx`)
+  })
+
+  it('should show dashboard widgets', () => {
+    expect(
+      fixture.debugElement.queryAll(By.directive(SavedViewWidgetComponent))
+    ).toHaveLength(2)
+  })
+
+  it('should end tour service if still running and welcome widget dismissed', () => {
+    jest.spyOn(tourService, 'getStatus').mockReturnValueOnce(1)
+    const endSpy = jest.spyOn(tourService, 'end')
+    component.completeTour()
+    expect(endSpy).toHaveBeenCalled()
+  })
+
+  it('should save tour completion if it was stopped and welcome widget dismissed', () => {
+    jest.spyOn(tourService, 'getStatus').mockReturnValueOnce(0)
+    const settingsCompleteTourSpy = jest.spyOn(settingsService, 'completeTour')
+    component.completeTour()
+    expect(settingsCompleteTourSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts
new file mode 100644 (file)
index 0000000..27ba45f
--- /dev/null
@@ -0,0 +1,165 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { Router } from '@angular/router'
+import { RouterTestingModule } from '@angular/router/testing'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { of, Subject } from 'rxjs'
+import { routes } from 'src/app/app-routing.module'
+import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
+import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
+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 { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import {
+  ConsumerStatusService,
+  FileStatus,
+} from 'src/app/services/consumer-status.service'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
+import { SavedViewWidgetComponent } from './saved-view-widget.component'
+
+const savedView: PaperlessSavedView = {
+  id: 1,
+  name: 'Saved View 1',
+  sort_field: 'added',
+  sort_reverse: true,
+  show_in_sidebar: true,
+  show_on_dashboard: true,
+  filter_rules: [
+    {
+      rule_type: FILTER_HAS_TAGS_ALL,
+      value: '1,2',
+    },
+  ],
+}
+
+const documentResults = [
+  {
+    id: 2,
+    title: 'doc2',
+  },
+  {
+    id: 3,
+    title: 'doc3',
+  },
+]
+
+describe('SavedViewWidgetComponent', () => {
+  let component: SavedViewWidgetComponent
+  let fixture: ComponentFixture<SavedViewWidgetComponent>
+  let documentService: DocumentService
+  let consumerStatusService: ConsumerStatusService
+  let documentListViewService: DocumentListViewService
+  let router: Router
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        SavedViewWidgetComponent,
+        WidgetFrameComponent,
+        IfPermissionsDirective,
+        CustomDatePipe,
+        DocumentTitlePipe,
+      ],
+      providers: [
+        PermissionsGuard,
+        DocumentService,
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+          },
+        },
+        CustomDatePipe,
+        DatePipe,
+      ],
+      imports: [
+        HttpClientTestingModule,
+        NgbModule,
+        RouterTestingModule.withRoutes(routes),
+      ],
+    }).compileComponents()
+
+    documentService = TestBed.inject(DocumentService)
+    consumerStatusService = TestBed.inject(ConsumerStatusService)
+    documentListViewService = TestBed.inject(DocumentListViewService)
+    router = TestBed.inject(Router)
+    fixture = TestBed.createComponent(SavedViewWidgetComponent)
+    component = fixture.componentInstance
+    component.savedView = savedView
+
+    fixture.detectChanges()
+  })
+
+  it('should show a list of documents', () => {
+    jest.spyOn(documentService, 'listFiltered').mockReturnValue(
+      of({
+        all: [2, 3],
+        count: 2,
+        results: documentResults,
+      })
+    )
+    component.ngOnInit()
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain('doc2')
+    expect(fixture.debugElement.nativeElement.textContent).toContain('doc3')
+  })
+
+  it('should call api endpoint and load results', () => {
+    const listAllSpy = jest.spyOn(documentService, 'listFiltered')
+    listAllSpy.mockReturnValue(
+      of({
+        all: [2, 3],
+        count: 2,
+        results: documentResults,
+      })
+    )
+    component.ngOnInit()
+    expect(listAllSpy).toHaveBeenCalledWith(
+      1,
+      10,
+      savedView.sort_field,
+      savedView.sort_reverse,
+      savedView.filter_rules,
+      {
+        truncate_content: true,
+      }
+    )
+    fixture.detectChanges()
+    expect(component.documents).toEqual(documentResults)
+  })
+
+  it('should reload on document consumption finished', () => {
+    const fileStatusSubject = new Subject<FileStatus>()
+    jest
+      .spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
+      .mockReturnValue(fileStatusSubject)
+    const reloadSpy = jest.spyOn(component, 'reload')
+    component.ngOnInit()
+    fileStatusSubject.next(new FileStatus())
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should navigate on showAll', () => {
+    const routerSpy = jest.spyOn(router, 'navigate')
+    component.showAll()
+    expect(routerSpy).toHaveBeenCalledWith(['view', savedView.id])
+    savedView.show_in_sidebar = false
+    component.showAll()
+    expect(routerSpy).toHaveBeenCalledWith(['documents'], {
+      queryParams: { view: savedView.id },
+    })
+  })
+
+  it('should navigate via quickfilter on click tag', () => {
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    component.clickTag({ id: 11, name: 'Tag11' }, new MouseEvent('click'))
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_HAS_TAGS_ALL, value: '11' },
+    ])
+  })
+})
diff --git a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts
new file mode 100644 (file)
index 0000000..da07b71
--- /dev/null
@@ -0,0 +1,110 @@
+import { TestBed } from '@angular/core/testing'
+import { StatisticsWidgetComponent } from './statistics-widget.component'
+import { ComponentFixture } from '@angular/core/testing'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
+import { environment } from 'src/environments/environment'
+import { RouterTestingModule } from '@angular/router/testing'
+import { routes } from 'src/app/app-routing.module'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+
+describe('StatisticsWidgetComponent', () => {
+  let component: StatisticsWidgetComponent
+  let fixture: ComponentFixture<StatisticsWidgetComponent>
+  let httpTestingController: HttpTestingController
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [StatisticsWidgetComponent, WidgetFrameComponent],
+      providers: [PermissionsGuard],
+      imports: [
+        HttpClientTestingModule,
+        NgbModule,
+        RouterTestingModule.withRoutes(routes),
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(StatisticsWidgetComponent)
+    component = fixture.componentInstance
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+
+    fixture.detectChanges()
+  })
+
+  it('should call api statistics endpoint', () => {
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}statistics/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should display inbox link with count', () => {
+    const mockStats = {
+      documents_total: 200,
+      documents_inbox: 18,
+      inbox_tag: 10,
+    }
+
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}statistics/`
+    )
+
+    req.flush(mockStats)
+    fixture.detectChanges()
+
+    const goToInboxSpy = jest.spyOn(component, 'goToInbox')
+
+    expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
+      'inbox:18'
+    )
+    const link = fixture.nativeElement.querySelector('a') as HTMLAnchorElement
+    expect(link).not.toBeNull()
+    link.click()
+    expect(goToInboxSpy).toHaveBeenCalled()
+  })
+
+  it('should display mime types with counts', () => {
+    const mockStats = {
+      documents_total: 200,
+      documents_inbox: 18,
+      inbox_tag: 10,
+      document_file_type_counts: [
+        {
+          mime_type: 'application/pdf',
+          mime_type_count: 160,
+        },
+        {
+          mime_type: 'text/plain',
+          mime_type_count: 20,
+        },
+        {
+          mime_type: 'text/csv',
+          mime_type_count: 20,
+        },
+      ],
+      character_count: 162312,
+    }
+
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}statistics/`
+    )
+
+    req.flush(mockStats)
+    fixture.detectChanges()
+
+    expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
+      'PDF(80%)'
+    )
+    expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
+      'TXT(10%)'
+    )
+    expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
+      'CSV(10%)'
+    )
+  })
+})
diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts
new file mode 100644 (file)
index 0000000..70bd983
--- /dev/null
@@ -0,0 +1,173 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { By } from '@angular/platform-browser'
+import { RouterTestingModule } from '@angular/router/testing'
+import {
+  NgbModule,
+  NgbAlertModule,
+  NgbAlert,
+  NgbCollapse,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgxFileDropModule } from 'ngx-file-drop'
+import { routes } from 'src/app/app-routing.module'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+import {
+  ConsumerStatusService,
+  FileStatus,
+  FileStatusPhase,
+} from 'src/app/services/consumer-status.service'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
+import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
+import { UploadFileWidgetComponent } from './upload-file-widget.component'
+
+describe('UploadFileWidgetComponent', () => {
+  let component: UploadFileWidgetComponent
+  let fixture: ComponentFixture<UploadFileWidgetComponent>
+  let consumerStatusService: ConsumerStatusService
+  let uploadDocumentsService: UploadDocumentsService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        UploadFileWidgetComponent,
+        WidgetFrameComponent,
+        IfPermissionsDirective,
+      ],
+      providers: [
+        PermissionsGuard,
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        NgbModule,
+        RouterTestingModule.withRoutes(routes),
+        NgxFileDropModule,
+        NgbAlertModule,
+      ],
+    }).compileComponents()
+
+    consumerStatusService = TestBed.inject(ConsumerStatusService)
+    uploadDocumentsService = TestBed.inject(UploadDocumentsService)
+    fixture = TestBed.createComponent(UploadFileWidgetComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support drop files', () => {
+    const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
+    component.dropped([])
+    expect(uploadSpy).toHaveBeenCalled()
+    // coverage
+    component.fileLeave(null)
+    component.fileOver(null)
+  })
+
+  it('should generate stats summary', () => {
+    mockConsumerStatuses(consumerStatusService)
+    expect(component.getStatusSummary()).toEqual(
+      'Processing: 6, Failed: 1, Added: 4'
+    )
+  })
+
+  it('should report an upload progress summary', () => {
+    mockConsumerStatuses(consumerStatusService)
+    expect(component.getTotalUploadProgress()).toEqual(0.75)
+  })
+
+  it('should change color by status phase', () => {
+    const processingStatus = new FileStatus()
+    processingStatus.phase = FileStatusPhase.PROCESSING
+    expect(component.getStatusColor(processingStatus)).toEqual('primary')
+    const failedStatus = new FileStatus()
+    failedStatus.phase = FileStatusPhase.FAILED
+    expect(component.getStatusColor(failedStatus)).toEqual('danger')
+    const successStatus = new FileStatus()
+    successStatus.phase = FileStatusPhase.SUCCESS
+    expect(component.getStatusColor(successStatus)).toEqual('success')
+  })
+
+  it('should enforce a maximum number of alerts', () => {
+    mockConsumerStatuses(consumerStatusService)
+    fixture.detectChanges()
+    // 5 total, 1 hidden
+    expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
+      6
+    )
+    expect(
+      fixture.debugElement
+        .query(By.directive(NgbCollapse))
+        .queryAll(By.directive(NgbAlert))
+    ).toHaveLength(1)
+  })
+
+  it('should allow dismissing an alert', () => {
+    const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
+    component.dismiss(new FileStatus())
+    expect(dismissSpy).toHaveBeenCalled()
+  })
+
+  it('should allow dismissing all alerts', () => {
+    const dismissSpy = jest.spyOn(consumerStatusService, 'dismissCompleted')
+    component.dismissCompleted()
+    expect(dismissSpy).toHaveBeenCalled()
+  })
+})
+
+function mockConsumerStatuses(consumerStatusService) {
+  const partialUpload1 = new FileStatus()
+  partialUpload1.currentPhaseProgress = 50
+  partialUpload1.currentPhaseMaxProgress = 50
+  const partialUpload2 = new FileStatus()
+  partialUpload2.currentPhaseProgress = 25
+  partialUpload2.currentPhaseMaxProgress = 50
+  jest
+    .spyOn(consumerStatusService, 'getConsumerStatus')
+    .mockImplementation((phase) => {
+      switch (phase) {
+        case FileStatusPhase.FAILED:
+          return [new FileStatus()]
+        case FileStatusPhase.PROCESSING:
+          return [new FileStatus(), new FileStatus()]
+        case FileStatusPhase.STARTED:
+          return [new FileStatus(), new FileStatus(), new FileStatus()]
+        case FileStatusPhase.SUCCESS:
+          return [
+            new FileStatus(),
+            new FileStatus(),
+            new FileStatus(),
+            new FileStatus(),
+          ]
+        case FileStatusPhase.UPLOADING:
+          return [partialUpload1, partialUpload2]
+        default:
+          return [
+            new FileStatus(),
+            new FileStatus(),
+            new FileStatus(),
+            new FileStatus(),
+            new FileStatus(),
+            new FileStatus(),
+          ]
+      }
+    })
+  jest
+    .spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
+    .mockImplementation(() => {
+      return [
+        new FileStatus(),
+        new FileStatus(),
+        new FileStatus(),
+        new FileStatus(),
+        new FileStatus(),
+        new FileStatus(),
+      ]
+    })
+}
index 2c6bf38e2884b2c8f0ce706d650ddebdd91d805d..96c8cdb255ca546a3ebcb8b7ad8b35a17f30338c 100644 (file)
@@ -69,9 +69,6 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
     return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
   }
 
-  getStatusCompleted() {
-    return this.consumerStatusService.getConsumerStatusCompleted()
-  }
   getTotalUploadProgress() {
     let current = 0
     let max = 0
diff --git a/src-ui/src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.spec.ts
new file mode 100644 (file)
index 0000000..07f0658
--- /dev/null
@@ -0,0 +1,33 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { By } from '@angular/platform-browser'
+import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
+import { WelcomeWidgetComponent } from './welcome-widget.component'
+
+describe('WelcomeWidgetComponent', () => {
+  let component: WelcomeWidgetComponent
+  let fixture: ComponentFixture<WelcomeWidgetComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [WelcomeWidgetComponent, WidgetFrameComponent],
+      providers: [PermissionsGuard],
+      imports: [NgbAlertModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(WelcomeWidgetComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should be dismissable', () => {
+    let dismissResult
+    component.dismiss.subscribe(() => (dismissResult = true))
+    fixture.debugElement
+      .query(By.directive(NgbAlert))
+      .triggerEventHandler('closed')
+    expect(dismissResult).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.spec.ts
new file mode 100644 (file)
index 0000000..59089de
--- /dev/null
@@ -0,0 +1,53 @@
+import { Component } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { By } from '@angular/platform-browser'
+import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+import { WidgetFrameComponent } from './widget-frame.component'
+
+@Component({
+  template: `
+    <div>
+      <button
+        *appIfObjectPermissions="{
+          object: { id: 2, owner: user1 },
+          action: 'view'
+        }"
+      >
+        Some Text
+      </button>
+    </div>
+  `,
+})
+class TestComponent extends WidgetFrameComponent {}
+
+describe('WidgetFrameComponent', () => {
+  let component: WidgetFrameComponent
+  let fixture: ComponentFixture<WidgetFrameComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [WidgetFrameComponent, WidgetFrameComponent],
+      providers: [PermissionsGuard],
+      imports: [NgbAlertModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(WidgetFrameComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should show title', () => {
+    component.title = 'Foo'
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain('Foo')
+  })
+
+  it('should show loading indicator', () => {
+    expect(fixture.debugElement.query(By.css('.spinner-border'))).toBeNull()
+    component.loading = true
+    fixture.detectChanges()
+    expect(fixture.debugElement.query(By.css('.spinner-border'))).not.toBeNull()
+  })
+})
diff --git a/src-ui/src/app/components/document-asn/document-asn.component.spec.ts b/src-ui/src/app/components/document-asn/document-asn.component.spec.ts
new file mode 100644 (file)
index 0000000..62b1113
--- /dev/null
@@ -0,0 +1,58 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'
+import { of } from 'rxjs'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { DocumentAsnComponent } from './document-asn.component'
+import { RouterTestingModule } from '@angular/router/testing'
+import { FilterRule } from 'src/app/data/filter-rule'
+import { routes } from 'src/app/app-routing.module'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+
+describe('DocumentAsnComponent', () => {
+  let component: DocumentAsnComponent
+  let fixture: ComponentFixture<DocumentAsnComponent>
+  let router: Router
+  let activatedRoute: ActivatedRoute
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [DocumentAsnComponent],
+      providers: [
+        {
+          provide: DocumentService,
+          useValue: {
+            listAllFilteredIds: (rules: FilterRule[]) =>
+              rules[0].value === '1234' ? of([1]) : of([]),
+          },
+        },
+        PermissionsGuard,
+      ],
+      imports: [RouterTestingModule.withRoutes(routes)],
+    }).compileComponents()
+
+    router = TestBed.inject(Router)
+    activatedRoute = TestBed.inject(ActivatedRoute)
+    fixture = TestBed.createComponent(DocumentAsnComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should navigate on valid asn', () => {
+    jest
+      .spyOn(activatedRoute, 'paramMap', 'get')
+      .mockReturnValue(of(convertToParamMap({ id: '1234' })))
+    const navigateSpy = jest.spyOn(router, 'navigate')
+    component.ngOnInit()
+    expect(navigateSpy).toHaveBeenCalledWith(['documents', 1])
+  })
+
+  it('should 404 on invalid asn', () => {
+    jest
+      .spyOn(activatedRoute, 'paramMap', 'get')
+      .mockReturnValue(of(convertToParamMap({ id: '5578' })))
+    const navigateSpy = jest.spyOn(router, 'navigate')
+    component.ngOnInit()
+    expect(navigateSpy).toHaveBeenCalledWith(['404'])
+  })
+})
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
new file mode 100644 (file)
index 0000000..0e6a99d
--- /dev/null
@@ -0,0 +1,770 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+  discardPeriodicTasks,
+} 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,
+  NgbModalModule,
+  NgbModalRef,
+  NgbDateStruct,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { PdfViewerComponent } from 'ng2-pdf-viewer'
+import { of, throwError } from 'rxjs'
+import { routes } from 'src/app/app-routing.module'
+import {
+  FILTER_FULLTEXT_MORELIKE,
+  FILTER_CORRESPONDENT,
+  FILTER_DOCUMENT_TYPE,
+  FILTER_STORAGE_PATH,
+  FILTER_HAS_TAGS_ALL,
+  FILTER_CREATED_AFTER,
+  FILTER_CREATED_BEFORE,
+} from 'src/app/data/filter-rule-type'
+import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
+import { PaperlessDocument } from 'src/app/data/paperless-document'
+import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
+import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+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 { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
+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 { 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 { 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 { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
+import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
+import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
+import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { DateComponent } from '../common/input/date/date.component'
+import { NumberComponent } from '../common/input/number/number.component'
+import { PermissionsFormComponent } from '../common/input/permissions/permissions-form/permissions-form.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 { DocumentNotesComponent } from '../document-notes/document-notes.component'
+import { DocumentDetailComponent } from './document-detail.component'
+
+const doc: PaperlessDocument = {
+  id: 3,
+  title: 'Doc 3',
+  correspondent: 11,
+  document_type: 21,
+  storage_path: 31,
+  tags: [41, 42, 43],
+  content: 'text content',
+  added: new Date(),
+  created: new Date(),
+  archive_serial_number: null,
+  original_file_name: 'file.pdf',
+  owner: null,
+  user_can_change: true,
+  notes: [
+    {
+      created: new Date(),
+      note: 'note 1',
+      user: 1,
+    },
+    {
+      created: new Date(),
+      note: 'note 2',
+      user: 2,
+    },
+  ],
+}
+
+describe('DocumentDetailComponent', () => {
+  let component: DocumentDetailComponent
+  let fixture: ComponentFixture<DocumentDetailComponent>
+  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 currentUserCan = true
+  let currentUserHasObjectPermissions = true
+  let currentUserOwnsObject = true
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DocumentDetailComponent,
+        DocumentTitlePipe,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+        TagsComponent,
+        SelectComponent,
+        TextComponent,
+        NumberComponent,
+        DateComponent,
+        DocumentNotesComponent,
+        CustomDatePipe,
+        DocumentTypeEditDialogComponent,
+        CorrespondentEditDialogComponent,
+        StoragePathEditDialogComponent,
+        IfOwnerDirective,
+        PermissionsFormComponent,
+        SafeHtmlPipe,
+        ConfirmDialogComponent,
+        PdfViewerComponent,
+        SafeUrlPipe,
+      ],
+      providers: [
+        DocumentTitlePipe,
+        {
+          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',
+                  },
+                ],
+              }),
+          },
+        },
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => currentUserCan,
+            currentUserHasObjectPermissions: () =>
+              currentUserHasObjectPermissions,
+            currentUserOwnsObject: () => currentUserOwnsObject,
+          },
+        },
+        PermissionsGuard,
+        CustomDatePipe,
+        DatePipe,
+      ],
+      imports: [
+        RouterTestingModule.withRoutes(routes),
+        HttpClientTestingModule,
+        NgbModule,
+        NgSelectModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModalModule,
+      ],
+    }).compileComponents()
+
+    router = TestBed.inject(Router)
+    activatedRoute = TestBed.inject(ActivatedRoute)
+    jest
+      .spyOn(activatedRoute, 'paramMap', 'get')
+      .mockReturnValue(of(convertToParamMap({ id: 3 })))
+    openDocumentsService = TestBed.inject(OpenDocumentsService)
+    documentService = TestBed.inject(DocumentService)
+    modalService = TestBed.inject(NgbModal)
+    toastService = TestBed.inject(ToastService)
+    documentListViewService = TestBed.inject(DocumentListViewService)
+    settingsService = TestBed.inject(SettingsService)
+    fixture = TestBed.createComponent(DocumentDetailComponent)
+    component = fixture.componentInstance
+  })
+
+  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(5) // DocumentDetailNavIDs.Notes
+  })
+
+  it('should change url on tab switch', () => {
+    initNormally()
+    const navigateSpy = jest.spyOn(router, 'navigate')
+    component.nav.select(5)
+    component.nav.navChange.next({
+      activeId: 1,
+      nextId: 5,
+      preventDefault: () => {},
+    })
+    fixture.detectChanges()
+    expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'notes'])
+  })
+
+  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 wasnt 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 load already-opened document via param', () => {
+    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
+    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc)
+    fixture.detectChanges() // calls ngOnInit
+    expect(component.document).toEqual(doc)
+  })
+
+  it('should disable form if user cannot edit', () => {
+    currentUserHasObjectPermissions = false
+    initNormally()
+    expect(component.documentForm.disabled).toBeTruthy()
+  })
+
+  it('should support creating document type', () => {
+    initNormally()
+    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)
+  })
+
+  it('should support creating correspondent', () => {
+    initNormally()
+    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)
+  })
+
+  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'])
+  })
+
+  it('should 404 on invalid id', () => {
+    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(null))
+    const navigateSpy = jest.spyOn(router, 'navigate')
+    fixture.detectChanges()
+    expect(navigateSpy).toHaveBeenCalledWith(['404'])
+  })
+
+  it('should support save, close and show success toast', () => {
+    initNormally()
+    component.title = 'Foo Bar'
+    const closeSpy = jest.spyOn(component, 'close')
+    const updateSpy = jest.spyOn(documentService, 'update')
+    const toastSpy = jest.spyOn(toastService, 'showInfo')
+    updateSpy.mockImplementation((o) => of(doc))
+    component.save()
+    expect(updateSpy).toHaveBeenCalled()
+    expect(closeSpy).toHaveBeenCalled()
+    expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
+  })
+
+  it('should show toast error on save if error occurs', () => {
+    currentUserHasObjectPermissions = true
+    initNormally()
+    component.title = 'Foo Bar'
+    const closeSpy = jest.spyOn(component, 'close')
+    const updateSpy = jest.spyOn(documentService, 'update')
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    updateSpy.mockImplementation(() =>
+      throwError(() => new Error('failed to save'))
+    )
+    component.save()
+    expect(updateSpy).toHaveBeenCalled()
+    expect(closeSpy).not.toHaveBeenCalled()
+    expect(toastSpy).toHaveBeenCalledWith(
+      'Error saving document: failed to save'
+    )
+  })
+
+  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 updateSpy = jest.spyOn(documentService, 'update')
+    const toastSpy = jest.spyOn(toastService, 'showInfo')
+    updateSpy.mockImplementation(() =>
+      throwError(() => new Error('failed to save'))
+    )
+    component.save()
+    expect(updateSpy).toHaveBeenCalled()
+    expect(closeSpy).toHaveBeenCalled()
+    expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
+  })
+
+  it('should allow save and next', () => {
+    initNormally()
+    const nextDocId = 100
+    component.title = 'Foo Bar'
+    const updateSpy = jest.spyOn(documentService, 'update')
+    updateSpy.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(updateSpy).toHaveBeenCalled()
+    expect(navigateSpy).toHaveBeenCalledWith(['documents', nextDocId])
+    expect
+  })
+
+  it('should show toast error on saveAll if error occurs', () => {
+    currentUserHasObjectPermissions = true
+    initNormally()
+    component.title = 'Foo Bar'
+    const closeSpy = jest.spyOn(component, 'close')
+    const updateSpy = jest.spyOn(documentService, 'update')
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    updateSpy.mockImplementation(() =>
+      throwError(() => new Error('failed to save'))
+    )
+    component.saveEditNext()
+    expect(updateSpy).toHaveBeenCalled()
+    expect(closeSpy).not.toHaveBeenCalled()
+    expect(toastSpy).toHaveBeenCalledWith(
+      'Error saving document: failed to save'
+    )
+  })
+
+  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 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 redo ocr, 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.redoOcr()
+    const modalCloseSpy = jest.spyOn(openModal, 'close')
+    openModal.componentInstance.confirmClicked.next()
+    expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'redo_ocr', {})
+    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.redoOcr()
+    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.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 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',
+      last_correspondence: new Date(),
+    } as PaperlessCorrespondent
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    component.filterDocuments([object])
+    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 PaperlessDocumentType
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    component.filterDocuments([object])
+    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 PaperlessStoragePath
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    component.filterDocuments([object])
+    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 PaperlessTag
+    const object2 = {
+      id: 23,
+      name: 'Tag22',
+      is_inbox_tag: true,
+      color: '#ff0000',
+      text_color: '#000000',
+    } as PaperlessTag
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    component.filterDocuments([object1, object2])
+    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.metadata = { has_archive_version: true }
+    jest.spyOn(settingsService, 'get').mockReturnValue(false)
+    expect(component.useNativePdfViewer).toBeFalsy()
+    fixture.detectChanges()
+    expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
+  })
+
+  it('should display native pdf viewer if enabled', () => {
+    initNormally()
+    component.metadata = { has_archive_version: true }
+    jest.spyOn(settingsService, 'get').mockReturnValue(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).toHaveBeenCalled()
+  })
+
+  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',
+      10000,
+      error
+    )
+  })
+
+  function initNormally() {
+    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
+    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
+    jest
+      .spyOn(openDocumentsService, 'openDocument')
+      .mockReturnValueOnce(of(true))
+    fixture.detectChanges()
+  }
+})
index 31e477d766bdbd9526a4253d5d98d7138394b1b2..654f68a50201afbbcead241ec8fb78188a6e61b9 100644 (file)
@@ -40,9 +40,6 @@ import {
   FILTER_CORRESPONDENT,
   FILTER_CREATED_AFTER,
   FILTER_CREATED_BEFORE,
-  FILTER_CREATED_DAY,
-  FILTER_CREATED_MONTH,
-  FILTER_CREATED_YEAR,
   FILTER_DOCUMENT_TYPE,
   FILTER_FULLTEXT_MORELIKE,
   FILTER_HAS_TAGS_ALL,
@@ -62,8 +59,9 @@ import { UserService } from 'src/app/services/rest/user.service'
 import { PaperlessDocumentNote } from 'src/app/data/paperless-document-note'
 import { HttpClient } from '@angular/common/http'
 import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
-import { FilterRule } from 'src/app/data/filter-rule'
+import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
 import { ObjectWithId } from 'src/app/data/object-with-id'
+import { FilterRule } from 'src/app/data/filter-rule'
 import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
 
 enum DocumentDetailNavIDs {
@@ -438,7 +436,7 @@ export class DocumentDetailComponent
     var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
       backdrop: 'static',
     })
-    modal.componentInstance.dialogMode = 'create'
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
     if (newName) modal.componentInstance.object = { name: newName }
     modal.componentInstance.succeeded
       .pipe(
@@ -459,7 +457,7 @@ export class DocumentDetailComponent
     var modal = this.modalService.open(CorrespondentEditDialogComponent, {
       backdrop: 'static',
     })
-    modal.componentInstance.dialogMode = 'create'
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
     if (newName) modal.componentInstance.object = { name: newName }
     modal.componentInstance.succeeded
       .pipe(
@@ -482,7 +480,7 @@ export class DocumentDetailComponent
     var modal = this.modalService.open(StoragePathEditDialogComponent, {
       backdrop: 'static',
     })
-    modal.componentInstance.dialogMode = 'create'
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
     if (newName) modal.componentInstance.object = { name: newName }
     modal.componentInstance.succeeded
       .pipe(
diff --git a/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts
new file mode 100644 (file)
index 0000000..c2eab42
--- /dev/null
@@ -0,0 +1,51 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { MetadataCollapseComponent } from './metadata-collapse.component'
+import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
+
+const metadata = [
+  {
+    namespace: 'http://ns.adobe.com/pdf/1.3/',
+    prefix: 'pdf',
+    key: 'Producer',
+    value: 'pikepdf 2.2.0',
+  },
+  {
+    namespace: 'http://ns.adobe.com/xap/1.0/',
+    prefix: 'xmp',
+    key: 'ModifyDate',
+    value: '2020-12-21T08:42:26+00:00',
+  },
+]
+
+describe('MetadataCollapseComponent', () => {
+  let component: MetadataCollapseComponent
+  let fixture: ComponentFixture<MetadataCollapseComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [MetadataCollapseComponent],
+      providers: [],
+      imports: [NgbCollapseModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(MetadataCollapseComponent)
+    component = fixture.componentInstance
+  })
+
+  it('should display metadata', () => {
+    component.title = 'Foo'
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain('Foo')
+  })
+
+  it('should display metadata', () => {
+    component.metadata = metadata
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      'pikepdf 2.2.0'
+    )
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      'ModifyDate'
+    )
+  })
+})
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
new file mode 100644 (file)
index 0000000..bb95a9c
--- /dev/null
@@ -0,0 +1,869 @@
+import {
+  HttpTestingController,
+  HttpClientTestingModule,
+} from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { By } from '@angular/platform-browser'
+import {
+  NgbModal,
+  NgbModule,
+  NgbModalModule,
+  NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { of, throwError } from 'rxjs'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { FilterPipe } from 'src/app/pipes/filter.pipe'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
+import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
+import {
+  SelectionData,
+  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 { SettingsService } from 'src/app/services/settings.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { environment } from 'src/environments/environment'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
+import { ToggleableDropdownButtonComponent } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
+import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
+import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
+import { BulkEditorComponent } from './bulk-editor.component'
+import { SelectComponent } from '../../common/input/select/select.component'
+import { UserService } from 'src/app/services/rest/user.service'
+import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
+import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { GroupService } from 'src/app/services/rest/group.service'
+
+const selectionData: SelectionData = {
+  selected_tags: [
+    { id: 12, document_count: 3 },
+    { id: 22, document_count: 1 },
+    { id: 19, document_count: 0 },
+  ],
+  selected_correspondents: [{ id: 33, document_count: 1 }],
+  selected_document_types: [{ id: 44, document_count: 3 }],
+  selected_storage_paths: [
+    { id: 66, document_count: 3 },
+    { id: 55, document_count: 0 },
+  ],
+}
+
+describe('BulkEditorComponent', () => {
+  let component: BulkEditorComponent
+  let fixture: ComponentFixture<BulkEditorComponent>
+  let permissionsService: PermissionsService
+  let documentListViewService: DocumentListViewService
+  let documentService: DocumentService
+  let toastService: ToastService
+  let modalService: NgbModal
+  let httpTestingController: HttpTestingController
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        BulkEditorComponent,
+        IfPermissionsDirective,
+        FilterableDropdownComponent,
+        ToggleableDropdownButtonComponent,
+        FilterPipe,
+        ConfirmDialogComponent,
+        SafeHtmlPipe,
+        PermissionsDialogComponent,
+        PermissionsFormComponent,
+        SelectComponent,
+        PermissionsGroupComponent,
+        PermissionsUserComponent,
+      ],
+      providers: [
+        PermissionsService,
+        {
+          provide: TagService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  { id: 12, name: 'tag12' },
+                  { id: 22, name: 'tag22' },
+                ],
+              }),
+          },
+        },
+        {
+          provide: CorrespondentService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [{ id: 33, name: 'correspondent33' }],
+              }),
+          },
+        },
+        {
+          provide: DocumentTypeService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [{ id: 44, name: 'doctype44' }],
+              }),
+          },
+        },
+        {
+          provide: StoragePathService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  { id: 66, name: 'storagepath66' },
+                  { id: 55, name: 'storagepath55' },
+                ],
+              }),
+          },
+        },
+        FilterPipe,
+        SettingsService,
+        {
+          provide: UserService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [{ id: 1, username: 'user1' }],
+              }),
+          },
+        },
+        {
+          provide: GroupService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [],
+              }),
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModule,
+        NgbModalModule,
+        NgSelectModule,
+      ],
+    }).compileComponents()
+
+    permissionsService = TestBed.inject(PermissionsService)
+    documentListViewService = TestBed.inject(DocumentListViewService)
+    documentService = TestBed.inject(DocumentService)
+    toastService = TestBed.inject(ToastService)
+    modalService = TestBed.inject(NgbModal)
+    httpTestingController = TestBed.inject(HttpTestingController)
+
+    fixture = TestBed.createComponent(BulkEditorComponent)
+    component = fixture.componentInstance
+  })
+
+  afterEach(async () => {
+    httpTestingController.verify()
+  })
+
+  it('should apply selection data to tags menu', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    fixture.detectChanges()
+    expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 5, 7]))
+    jest
+      .spyOn(documentService, 'getSelectionData')
+      .mockReturnValue(of(selectionData))
+    component.openTagsDropdown()
+    expect(component.tagSelectionModel.selectionSize()).toEqual(1)
+  })
+
+  it('should apply selection data to correspondents menu', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    fixture.detectChanges()
+    expect(
+      component.correspondentSelectionModel.getSelectedItems()
+    ).toHaveLength(0)
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 5, 7]))
+    jest
+      .spyOn(documentService, 'getSelectionData')
+      .mockReturnValue(of(selectionData))
+    component.openCorrespondentDropdown()
+    expect(component.correspondentSelectionModel.items).toHaveLength(2)
+    expect(component.correspondentSelectionModel.selectionSize()).toEqual(0)
+  })
+
+  it('should apply selection data to doc types menu', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    fixture.detectChanges()
+    expect(
+      component.documentTypeSelectionModel.getSelectedItems()
+    ).toHaveLength(0)
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 5, 7]))
+    jest
+      .spyOn(documentService, 'getSelectionData')
+      .mockReturnValue(of(selectionData))
+    component.openDocumentTypeDropdown()
+    expect(component.documentTypeSelectionModel.selectionSize()).toEqual(1)
+  })
+
+  it('should apply selection data to storage path menu', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    fixture.detectChanges()
+    expect(
+      component.storagePathsSelectionModel.getSelectedItems()
+    ).toHaveLength(0)
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 5, 7]))
+    jest
+      .spyOn(documentService, 'getSelectionData')
+      .mockReturnValue(of(selectionData))
+    component.openStoragePathDropdown()
+    expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
+  })
+
+  it('should execute modify tags bulk operation', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = false
+    fixture.detectChanges()
+    component.setTags({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'modify_tags',
+      parameters: { add_tags: [101], remove_tags: [] },
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setTags({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirm()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
+      .flush(true)
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should set modal dialog text accordingly for tag edit confirmation', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setTags({
+      itemsToAdd: [],
+      itemsToRemove: [{ id: 101, name: 'Tag 101' }],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will remove the tag "Tag 101" from 2 selected document(s).'
+    )
+    modal.close()
+    component.setTags({
+      itemsToAdd: [],
+      itemsToRemove: [
+        { id: 101, name: 'Tag 101' },
+        { id: 102, name: 'Tag 102' },
+      ],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will remove the tags "Tag 101" and "Tag 102" from 2 selected document(s).'
+    )
+    modal.close()
+    component.setTags({
+      itemsToAdd: [
+        { id: 101, name: 'Tag 101' },
+        { id: 102, name: 'Tag 102' },
+      ],
+      itemsToRemove: [],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will add the tags "Tag 101" and "Tag 102" to 2 selected document(s).'
+    )
+    modal.close()
+    component.setTags({
+      itemsToAdd: [
+        { id: 101, name: 'Tag 101' },
+        { id: 102, name: 'Tag 102' },
+      ],
+      itemsToRemove: [{ id: 103, name: 'Tag 103' }],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will add the tags "Tag 101" and "Tag 102" and remove the tags "Tag 103" on 2 selected document(s).'
+    )
+  })
+
+  it('should execute modify correspondent bulk operation', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = false
+    fixture.detectChanges()
+    component.setCorrespondents({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'set_correspondent',
+      parameters: { correspondent: 101 },
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setCorrespondents({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirm()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
+      .flush(true)
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setCorrespondents({
+      itemsToAdd: [],
+      itemsToRemove: [{ id: 101, name: 'Correspondent 101' }],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will remove the correspondent from 2 selected document(s).'
+    )
+    modal.close()
+    component.setCorrespondents({
+      itemsToAdd: [{ id: 101, name: 'Correspondent 101' }],
+      itemsToRemove: [],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will assign the correspondent "Correspondent 101" to 2 selected document(s).'
+    )
+  })
+
+  it('should execute modify document type bulk operation', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = false
+    fixture.detectChanges()
+    component.setDocumentTypes({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'set_document_type',
+      parameters: { document_type: 101 },
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setDocumentTypes({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirm()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
+      .flush(true)
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should set modal dialog text accordingly for document type edit confirmation', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setDocumentTypes({
+      itemsToAdd: [],
+      itemsToRemove: [{ id: 101, name: 'DocType 101' }],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will remove the document type from 2 selected document(s).'
+    )
+    modal.close()
+    component.setDocumentTypes({
+      itemsToAdd: [{ id: 101, name: 'DocType 101' }],
+      itemsToRemove: [],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will assign the document type "DocType 101" to 2 selected document(s).'
+    )
+  })
+
+  it('should execute modify storage path bulk operation', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = false
+    fixture.detectChanges()
+    component.setStoragePaths({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'set_storage_path',
+      parameters: { storage_path: 101 },
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setStoragePaths({
+      itemsToAdd: [{ id: 101 }],
+      itemsToRemove: [],
+    })
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirm()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
+      .flush(true)
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should set modal dialog text accordingly for storage path edit confirmation', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setStoragePaths({
+      itemsToAdd: [],
+      itemsToRemove: [{ id: 101, name: 'StoragePath 101' }],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will remove the storage path from 2 selected document(s).'
+    )
+    modal.close()
+    component.setStoragePaths({
+      itemsToAdd: [{ id: 101, name: 'StoragePath 101' }],
+      itemsToRemove: [],
+    })
+    expect(modal.componentInstance.message).toEqual(
+      'This operation will assign the storage path "StoragePath 101" to 2 selected document(s).'
+    )
+  })
+
+  it('should only execute bulk operations when changes are detected', () => {
+    component.setTags({
+      itemsToAdd: [],
+      itemsToRemove: [],
+    })
+    component.setCorrespondents({
+      itemsToAdd: [],
+      itemsToRemove: [],
+    })
+    component.setDocumentTypes({
+      itemsToAdd: [],
+      itemsToRemove: [],
+    })
+    component.setStoragePaths({
+      itemsToAdd: [],
+      itemsToRemove: [],
+    })
+    httpTestingController.expectNone(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+  })
+
+  it('should support bulk delete with confirmation', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.applyDelete()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirm()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'delete',
+      parameters: {},
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should not be accessible with insufficient global permissions', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
+    fixture.detectChanges()
+    const dropdown = fixture.debugElement.query(
+      By.directive(FilterableDropdownComponent)
+    )
+    expect(dropdown).toBeNull()
+  })
+
+  it('should disable with insufficient object permissions', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(false)
+    fixture.detectChanges()
+    const button = fixture.debugElement
+      .query(By.directive(FilterableDropdownComponent))
+      .query(By.css('button'))
+    expect(button.nativeElement.disabled).toBeTruthy()
+  })
+
+  it('should show a warning toast on bulk edit error', () => {
+    jest
+      .spyOn(documentService, 'bulkEdit')
+      .mockReturnValue(
+        throwError(() => new Error('error executing bulk operation'))
+      )
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = false
+    fixture.detectChanges()
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    component.setTags({
+      itemsToAdd: [{ id: 0 }],
+      itemsToRemove: [],
+    })
+    expect(toastSpy).toHaveBeenCalled()
+  })
+
+  it('should support redo ocr', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.redoOcrSelected()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirm()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'redo_ocr',
+      parameters: {},
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should support bulk download with archive, originals or both and file formatting', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
+    fixture.detectChanges()
+    let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
+    //archive
+    component.downloadSelected()
+    expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false)
+    //originals
+    component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
+    component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
+    component.downloadSelected()
+    expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false)
+    //both
+    component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
+    component.downloadSelected()
+    expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false)
+    //formatting
+    component.downloadForm.get('downloadUseFormatting').patchValue(true)
+    component.downloadSelected()
+    expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true)
+
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/bulk_download/`
+    )
+  })
+
+  it('should support bulk permissions update', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    component.showConfirmationDialogs = true
+    fixture.detectChanges()
+    component.setPermissions()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirmClicked.next()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'set_permissions',
+      parameters: undefined,
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+})
diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts
new file mode 100644 (file)
index 0000000..407bb01
--- /dev/null
@@ -0,0 +1,129 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { By } from '@angular/platform-browser'
+import { RouterTestingModule } from '@angular/router/testing'
+import {
+  NgbPopoverModule,
+  NgbTooltipModule,
+  NgbProgressbarModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
+import { DocumentCardLargeComponent } from './document-card-large.component'
+
+const doc = {
+  id: 10,
+  title: 'Document 10',
+  tags: [3, 4, 5],
+  correspondent: 8,
+  document_type: 10,
+  storage_path: null,
+  notes: [
+    {
+      id: 11,
+      note: 'This is some note content bananas',
+    },
+  ],
+  content:
+    'Cupcake ipsum dolor sit amet ice cream. Donut shortbread cheesecake caramels tiramisu pastry caramels chocolate bar. Tart tootsie roll muffin icing cotton candy topping sweet roll. Pie lollipop dragée sesame snaps donut tart pudding. Oat cake apple pie danish danish candy canes. Shortbread candy canes sesame snaps muffin tiramisu marshmallow chocolate bar halvah. Cake lemon drops candy apple pie carrot cake bonbon halvah pastry gummi bears. Sweet roll candy ice cream sesame snaps marzipan cookie ice cream. Cake cheesecake apple pie muffin candy toffee lollipop. Carrot cake oat cake cookie biscuit cupcake cake marshmallow. Sweet roll jujubes carrot cake cheesecake cake candy canes sweet roll gingerbread jelly beans. Apple pie sugar plum oat cake halvah cake. Pie oat cake chocolate cake cookie gingerbread marzipan. Lemon drops cheesecake lollipop danish marzipan candy.',
+}
+
+describe('DocumentCardLargeComponent', () => {
+  let component: DocumentCardLargeComponent
+  let fixture: ComponentFixture<DocumentCardLargeComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DocumentCardLargeComponent,
+        DocumentTitlePipe,
+        CustomDatePipe,
+        IfPermissionsDirective,
+        SafeUrlPipe,
+      ],
+      providers: [DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule,
+        NgbPopoverModule,
+        NgbTooltipModule,
+        NgbProgressbarModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(DocumentCardLargeComponent)
+    component = fixture.componentInstance
+    component.document = doc
+    fixture.detectChanges()
+  })
+
+  it('should display a document', () => {
+    expect(fixture.nativeElement.textContent).toContain('Document 10')
+    expect(fixture.nativeElement.textContent).toContain('Cupcake ipsum')
+  })
+
+  it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
+    component.mouseEnterPreview()
+    expect(component.popover.isOpen()).toBeTruthy()
+    expect(component.popoverHidden).toBeTruthy()
+    tick(600)
+    expect(component.popoverHidden).toBeFalsy()
+    component.mouseLeaveCard()
+
+    component.mouseEnterPreview()
+    tick(100)
+    component.mouseLeavePreview()
+    tick(600)
+    expect(component.popover.isOpen()).toBeFalsy()
+  }))
+
+  it('should trim content', () => {
+    expect(component.contentTrimmed).toHaveLength(503) // includes ...
+  })
+
+  it('should display search hits with colored score', () => {
+    // high
+    component.document.__search_hit__ = {
+      score: 0.9,
+      rank: 1,
+      highlights: 'cheesecake',
+    }
+    fixture.detectChanges()
+    let search_hit = fixture.debugElement.query(By.css('.search-score'))
+    expect(search_hit).not.toBeUndefined()
+    expect(component.searchScoreClass).toEqual('success')
+
+    // medium
+    component.document.__search_hit__.score = 0.6
+    fixture.detectChanges()
+    search_hit = fixture.debugElement.query(By.css('.search-score'))
+    expect(search_hit).not.toBeUndefined()
+    expect(component.searchScoreClass).toEqual('warning')
+
+    // low
+    component.document.__search_hit__.score = 0.1
+    fixture.detectChanges()
+    search_hit = fixture.debugElement.query(By.css('.search-score'))
+    expect(search_hit).not.toBeUndefined()
+    expect(component.searchScoreClass).toEqual('danger')
+  })
+
+  it('should display note highlights', () => {
+    component.document.__search_hit__ = {
+      score: 0.9,
+      rank: 1,
+      note_highlights: '<span>bananas</span>',
+    }
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent).toContain('bananas')
+    expect(component.searchNoteHighlights).toContain('<span>bananas</span>')
+  })
+})
index d2153fb6229689fbf3c4c1053f1399d0119e00ff..8d2a594052865add47c2698c49c5405e1454d637 100644 (file)
@@ -133,7 +133,7 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
 
   get contentTrimmed() {
     return (
-      this.document.content.substr(0, 500) +
+      this.document.content.substring(0, 500) +
       (this.document.content.length > 500 ? '...' : '')
     )
   }
diff --git a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts
new file mode 100644 (file)
index 0000000..67081cb
--- /dev/null
@@ -0,0 +1,120 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { RouterTestingModule } from '@angular/router/testing'
+import {
+  NgbPopoverModule,
+  NgbTooltipModule,
+  NgbProgressbarModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
+import { DocumentCardSmallComponent } from './document-card-small.component'
+import { of } from 'rxjs'
+import { By } from '@angular/platform-browser'
+import { TagComponent } from '../../common/tag/tag.component'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+
+const doc = {
+  id: 10,
+  title: 'Document 10',
+  tags: [1, 2, 3, 4, 5, 6, 7, 8],
+  correspondent: 8,
+  document_type: 10,
+  storage_path: null,
+  notes: [
+    {
+      id: 11,
+      note: 'This is some note content bananas',
+    },
+  ],
+  tags$: of([
+    { id: 1, name: 'Tag1' },
+    { id: 2, name: 'Tag2' },
+    { id: 3, name: 'Tag3' },
+    { id: 4, name: 'Tag4' },
+    { id: 5, name: 'Tag5' },
+    { id: 6, name: 'Tag6' },
+    { id: 7, name: 'Tag7' },
+    { id: 8, name: 'Tag8' },
+  ]),
+  content:
+    'Cupcake ipsum dolor sit amet ice cream. Donut shortbread cheesecake caramels tiramisu pastry caramels chocolate bar. Tart tootsie roll muffin icing cotton candy topping sweet roll. Pie lollipop dragée sesame snaps donut tart pudding. Oat cake apple pie danish danish candy canes. Shortbread candy canes sesame snaps muffin tiramisu marshmallow chocolate bar halvah. Cake lemon drops candy apple pie carrot cake bonbon halvah pastry gummi bears. Sweet roll candy ice cream sesame snaps marzipan cookie ice cream. Cake cheesecake apple pie muffin candy toffee lollipop. Carrot cake oat cake cookie biscuit cupcake cake marshmallow. Sweet roll jujubes carrot cake cheesecake cake candy canes sweet roll gingerbread jelly beans. Apple pie sugar plum oat cake halvah cake. Pie oat cake chocolate cake cookie gingerbread marzipan. Lemon drops cheesecake lollipop danish marzipan candy.',
+}
+
+describe('DocumentCardSmallComponent', () => {
+  let component: DocumentCardSmallComponent
+  let fixture: ComponentFixture<DocumentCardSmallComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DocumentCardSmallComponent,
+        DocumentTitlePipe,
+        CustomDatePipe,
+        IfPermissionsDirective,
+        SafeUrlPipe,
+        TagComponent,
+      ],
+      providers: [DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule,
+        NgbPopoverModule,
+        NgbTooltipModule,
+        NgbProgressbarModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(DocumentCardSmallComponent)
+    component = fixture.componentInstance
+    component.document = Object.assign({}, doc)
+    fixture.detectChanges()
+  })
+
+  it('should display a document, limit tags to 5', () => {
+    expect(fixture.nativeElement.textContent).toContain('Document 10')
+    expect(
+      fixture.debugElement.queryAll(By.directive(TagComponent))
+    ).toHaveLength(5)
+    component.document.tags = [1, 2]
+    component.document.tags$ = of([
+      { id: 1 } as PaperlessTag,
+      { id: 2 } as PaperlessTag,
+    ])
+    fixture.detectChanges()
+    expect(
+      fixture.debugElement.queryAll(By.directive(TagComponent))
+    ).toHaveLength(2)
+  })
+
+  it('should increase limit tags to 6 if no notes', () => {
+    component.document.notes = []
+    fixture.detectChanges()
+    expect(
+      fixture.debugElement.queryAll(By.directive(TagComponent))
+    ).toHaveLength(6)
+  })
+
+  it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
+    component.mouseEnterPreview()
+    expect(component.popover.isOpen()).toBeTruthy()
+    expect(component.popoverHidden).toBeTruthy()
+    tick(600)
+    expect(component.popoverHidden).toBeFalsy()
+    component.mouseLeaveCard()
+
+    component.mouseEnterPreview()
+    tick(100)
+    component.mouseLeavePreview()
+    tick(600)
+    expect(component.popover.isOpen()).toBeFalsy()
+  }))
+})
diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts
new file mode 100644 (file)
index 0000000..2b14747
--- /dev/null
@@ -0,0 +1,591 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { DocumentListComponent } from './document-list.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { RouterTestingModule } from '@angular/router/testing'
+import { routes } from 'src/app/app-routing.module'
+import { FilterEditorComponent } from './filter-editor/filter-editor.component'
+import { PermissionsFilterDropdownComponent } from '../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
+import { DateDropdownComponent } from '../common/date-dropdown/date-dropdown.component'
+import { FilterableDropdownComponent } from '../common/filterable-dropdown/filterable-dropdown.component'
+import { PageHeaderComponent } from '../common/page-header/page-header.component'
+import { BulkEditorComponent } from './bulk-editor/bulk-editor.component'
+import { FilterPipe } from 'src/app/pipes/filter.pipe'
+import {
+  NgbDatepickerModule,
+  NgbDropdown,
+  NgbDropdownItem,
+  NgbDropdownModule,
+  NgbModal,
+  NgbModalRef,
+  NgbPagination,
+  NgbPopoverModule,
+  NgbTooltipModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { ToggleableDropdownButtonComponent } from '../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { DatePipe } from '@angular/common'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import {
+  ConsumerStatusService,
+  FileStatus,
+} from 'src/app/services/consumer-status.service'
+import { Subject, of, throwError } from 'rxjs'
+import { SavedViewService } from 'src/app/services/rest/saved-view.service'
+import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'
+import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
+import {
+  FILTER_FULLTEXT_MORELIKE,
+  FILTER_FULLTEXT_QUERY,
+  FILTER_HAS_TAGS_ANY,
+} from 'src/app/data/filter-rule-type'
+import { By } from '@angular/platform-browser'
+import { SortableDirective } from 'src/app/directives/sortable.directive'
+import { ToastService } from 'src/app/services/toast.service'
+import { DocumentCardSmallComponent } from './document-card-small/document-card-small.component'
+import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component'
+import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { UsernamePipe } from 'src/app/pipes/username.pipe'
+import { PaperlessDocument } from 'src/app/data/paperless-document'
+import {
+  DOCUMENT_SORT_FIELDS,
+  DOCUMENT_SORT_FIELDS_FULLTEXT,
+  DocumentService,
+} from 'src/app/services/rest/document.service'
+import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'
+import { TextComponent } from '../common/input/text/text.component'
+import { CheckComponent } from '../common/input/check/check.component'
+import { HttpErrorResponse } from '@angular/common/http'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+import { SettingsService } from 'src/app/services/settings.service'
+import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
+
+const docs: PaperlessDocument[] = [
+  {
+    id: 1,
+    title: 'Doc1',
+    notes: [],
+    tags$: new Subject(),
+    content: 'document content 1',
+  },
+  {
+    id: 2,
+    title: 'Doc2',
+    notes: [],
+    tags$: new Subject(),
+    content: 'document content 2',
+  },
+  {
+    id: 3,
+    title: 'Doc3',
+    notes: [],
+    tags$: new Subject(),
+    content: 'document content 3',
+  },
+]
+
+describe('DocumentListComponent', () => {
+  let component: DocumentListComponent
+  let fixture: ComponentFixture<DocumentListComponent>
+  let documentListService: DocumentListViewService
+  let documentService: DocumentService
+  let consumerStatusService: ConsumerStatusService
+  let savedViewService: SavedViewService
+  let router: Router
+  let activatedRoute: ActivatedRoute
+  let toastService: ToastService
+  let modalService: NgbModal
+  let settingsService: SettingsService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DocumentListComponent,
+        PageHeaderComponent,
+        FilterEditorComponent,
+        FilterableDropdownComponent,
+        DateDropdownComponent,
+        PermissionsFilterDropdownComponent,
+        ToggleableDropdownButtonComponent,
+        BulkEditorComponent,
+        ClearableBadgeComponent,
+        DocumentCardSmallComponent,
+        DocumentCardLargeComponent,
+        ConfirmDialogComponent,
+        SaveViewConfigDialogComponent,
+        TextComponent,
+        CheckComponent,
+        IfPermissionsDirective,
+        FilterPipe,
+        CustomDatePipe,
+        SortableDirective,
+        DocumentTitlePipe,
+        UsernamePipe,
+        SafeHtmlPipe,
+      ],
+      providers: [
+        FilterPipe,
+        CustomDatePipe,
+        DatePipe,
+        DocumentTitlePipe,
+        UsernamePipe,
+        SafeHtmlPipe,
+        PermissionsGuard,
+      ],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule.withRoutes(routes),
+        FormsModule,
+        ReactiveFormsModule,
+        NgbDropdownModule,
+        NgbDatepickerModule,
+        NgbPopoverModule,
+        NgbTooltipModule,
+      ],
+    }).compileComponents()
+
+    documentListService = TestBed.inject(DocumentListViewService)
+    documentService = TestBed.inject(DocumentService)
+    consumerStatusService = TestBed.inject(ConsumerStatusService)
+    savedViewService = TestBed.inject(SavedViewService)
+    router = TestBed.inject(Router)
+    activatedRoute = TestBed.inject(ActivatedRoute)
+    toastService = TestBed.inject(ToastService)
+    modalService = TestBed.inject(NgbModal)
+    settingsService = TestBed.inject(SettingsService)
+    fixture = TestBed.createComponent(DocumentListComponent)
+    component = fixture.componentInstance
+  })
+
+  it('should load display mode from local storage', () => {
+    window.localStorage.setItem('document-list:displayMode', 'largeCards')
+    fixture.detectChanges()
+    expect(component.displayMode).toEqual('largeCards')
+    component.displayMode = 'smallCards'
+    component.saveDisplayMode()
+    expect(window.localStorage.getItem('document-list:displayMode')).toEqual(
+      'smallCards'
+    )
+  })
+
+  it('should reload on new document consumed', () => {
+    const reloadSpy = jest.spyOn(documentListService, 'reload')
+    const fileStatusSubject = new Subject<FileStatus>()
+    jest
+      .spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
+      .mockReturnValue(fileStatusSubject)
+    fixture.detectChanges()
+    fileStatusSubject.next(new FileStatus())
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should show score sort fields on fulltext queries', () => {
+    documentListService.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '10',
+      },
+    ]
+    fixture.detectChanges()
+    expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS)
+
+    documentListService.filterRules = [
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'foo',
+      },
+    ]
+    fixture.detectChanges()
+    expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS_FULLTEXT)
+  })
+
+  it('should determine if filtered, support reset', () => {
+    fixture.detectChanges()
+    documentListService.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '10',
+      },
+    ]
+    documentListService.isReloading = false
+    fixture.detectChanges()
+    expect(component.isFiltered).toBeTruthy()
+    expect(fixture.nativeElement.textContent.match(/Reset/g)).toHaveLength(2)
+    component.resetFilters()
+    fixture.detectChanges()
+    expect(fixture.nativeElement.textContent.match(/Reset/g)).toHaveLength(1)
+  })
+
+  it('should load saved view from URL', () => {
+    const view: PaperlessSavedView = {
+      id: 10,
+      sort_field: 'added',
+      sort_reverse: true,
+      filter_rules: [
+        {
+          rule_type: FILTER_HAS_TAGS_ANY,
+          value: '20',
+        },
+      ],
+    }
+    const queryParams = { id: view.id.toString() }
+    const getSavedViewSpy = jest.spyOn(savedViewService, 'getCached')
+    getSavedViewSpy.mockReturnValue(of(view))
+    const activateSavedViewSpy = jest.spyOn(
+      documentListService,
+      'activateSavedViewWithQueryParams'
+    )
+    activateSavedViewSpy.mockImplementation((view, params) => {})
+    jest
+      .spyOn(activatedRoute, 'paramMap', 'get')
+      .mockReturnValue(of(convertToParamMap(queryParams)))
+    activatedRoute.snapshot.queryParams = queryParams
+    fixture.detectChanges()
+    expect(getSavedViewSpy).toHaveBeenCalledWith(view.id)
+    expect(activateSavedViewSpy).toHaveBeenCalledWith(
+      view,
+      convertToParamMap(queryParams)
+    )
+  })
+
+  it('should 404 on load saved view from URL if no view', () => {
+    jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(null)) // e.g. no saved view found
+    jest
+      .spyOn(activatedRoute, 'paramMap', 'get')
+      .mockReturnValue(of(convertToParamMap({ id: '10' })))
+    const navigateSpy = jest.spyOn(router, 'navigate')
+    fixture.detectChanges()
+    expect(navigateSpy).toHaveBeenCalledWith(['404'])
+  })
+
+  it('should load saved view from query params', () => {
+    const view: PaperlessSavedView = {
+      id: 10,
+      sort_field: 'added',
+      sort_reverse: true,
+      filter_rules: [
+        {
+          rule_type: FILTER_HAS_TAGS_ANY,
+          value: '20',
+        },
+      ],
+    }
+    const getSavedViewSpy = jest.spyOn(savedViewService, 'getCached')
+    getSavedViewSpy.mockReturnValue(of(view))
+    jest
+      .spyOn(activatedRoute, 'queryParamMap', 'get')
+      .mockReturnValue(of(convertToParamMap({ view: view.id.toString() })))
+    fixture.detectChanges()
+    expect(getSavedViewSpy).toHaveBeenCalledWith(view.id)
+  })
+
+  it('should support 3 different display modes', () => {
+    jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
+    fixture.detectChanges()
+    const displayModeButtons = fixture.debugElement.queryAll(
+      By.css('input[type="radio"]')
+    )
+    expect(component.displayMode).toEqual('smallCards')
+
+    displayModeButtons[0].nativeElement.checked = true
+    displayModeButtons[0].triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.displayMode).toEqual('details')
+    expect(fixture.debugElement.queryAll(By.css('tr'))).toHaveLength(3)
+
+    displayModeButtons[1].nativeElement.checked = true
+    displayModeButtons[1].triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.displayMode).toEqual('smallCards')
+    expect(
+      fixture.debugElement.queryAll(By.directive(DocumentCardSmallComponent))
+    ).toHaveLength(3)
+
+    displayModeButtons[2].nativeElement.checked = true
+    displayModeButtons[2].triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.displayMode).toEqual('largeCards')
+    expect(
+      fixture.debugElement.queryAll(By.directive(DocumentCardLargeComponent))
+    ).toHaveLength(3)
+  })
+
+  it('should support setting sort field', () => {
+    expect(documentListService.sortField).toEqual('created')
+    fixture.detectChanges()
+    const sortDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdown)
+    )[1]
+    const asnSortFieldButton = sortDropdown.query(By.directive(NgbDropdownItem))
+
+    asnSortFieldButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    expect(documentListService.sortField).toEqual('archive_serial_number')
+    documentListService.sortField = 'created'
+  })
+
+  it('should support setting sort field by table head', () => {
+    jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
+    fixture.detectChanges()
+    expect(documentListService.sortField).toEqual('created')
+
+    const detailsDisplayModeButton = fixture.debugElement.query(
+      By.css('input[type="radio"]')
+    )
+    detailsDisplayModeButton.nativeElement.checked = true
+    detailsDisplayModeButton.triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.displayMode).toEqual('details')
+
+    const sortTh = fixture.debugElement.query(By.directive(SortableDirective))
+    sortTh.triggerEventHandler('click')
+    fixture.detectChanges()
+    expect(documentListService.sortField).toEqual('archive_serial_number')
+    documentListService.sortField = 'created'
+    expect(documentListService.sortReverse).toBeFalsy()
+    component.listSortReverse = true
+    expect(documentListService.sortReverse).toBeTruthy()
+  })
+
+  it('should support select all, none, page & range', () => {
+    jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
+    jest
+      .spyOn(documentService, 'listAllFilteredIds')
+      .mockReturnValue(of(docs.map((d) => d.id)))
+    fixture.detectChanges()
+    expect(documentListService.selected.size).toEqual(0)
+    const docCards = fixture.debugElement.queryAll(
+      By.directive(DocumentCardLargeComponent)
+    )
+    const displayModeButtons = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )
+
+    const selectAllSpy = jest.spyOn(documentListService, 'selectAll')
+    displayModeButtons[2].triggerEventHandler('click')
+    expect(selectAllSpy).toHaveBeenCalled()
+    fixture.detectChanges()
+    expect(documentListService.selected.size).toEqual(3)
+    docCards.forEach((card) => {
+      expect(card.context.selected).toBeTruthy()
+    })
+
+    const selectNoneSpy = jest.spyOn(documentListService, 'selectNone')
+    displayModeButtons[0].triggerEventHandler('click')
+    expect(selectNoneSpy).toHaveBeenCalled()
+    fixture.detectChanges()
+    expect(documentListService.selected.size).toEqual(0)
+    docCards.forEach((card) => {
+      expect(card.context.selected).toBeFalsy()
+    })
+
+    const selectPageSpy = jest.spyOn(documentListService, 'selectPage')
+    displayModeButtons[1].triggerEventHandler('click')
+    expect(selectPageSpy).toHaveBeenCalled()
+    fixture.detectChanges()
+    expect(documentListService.selected.size).toEqual(3)
+    docCards.forEach((card) => {
+      expect(card.context.selected).toBeTruthy()
+    })
+
+    component.toggleSelected(docs[0], new MouseEvent('click'))
+    fixture.detectChanges()
+    expect(documentListService.selected.size).toEqual(2)
+    // reset
+    displayModeButtons[0].triggerEventHandler('click')
+    fixture.detectChanges()
+    expect(documentListService.selected.size).toEqual(0)
+
+    // select a range
+    component.toggleSelected(docs[0], new MouseEvent('click'))
+    component.toggleSelected(
+      docs[2],
+      new MouseEvent('click', { shiftKey: true })
+    )
+    fixture.detectChanges()
+    expect(documentListService.selected.size).toEqual(3)
+  })
+
+  it('should support saving an edited view', () => {
+    const view: PaperlessSavedView = {
+      id: 10,
+      name: 'Saved View 10',
+      sort_field: 'added',
+      sort_reverse: true,
+      filter_rules: [
+        {
+          rule_type: FILTER_HAS_TAGS_ANY,
+          value: '20',
+        },
+      ],
+    }
+    jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
+    const queryParams = { view: view.id.toString() }
+    jest
+      .spyOn(activatedRoute, 'queryParamMap', 'get')
+      .mockReturnValue(of(convertToParamMap(queryParams)))
+    activatedRoute.snapshot.queryParams = queryParams
+    router.routerState.snapshot.url = '/view/10/'
+    fixture.detectChanges()
+    expect(documentListService.activeSavedViewId).toEqual(10)
+
+    const modifiedView = Object.assign({}, view)
+    delete modifiedView.name
+    const savedViewServicePatch = jest.spyOn(savedViewService, 'patch')
+    savedViewServicePatch.mockReturnValue(of(modifiedView))
+    const toastSpy = jest.spyOn(toastService, 'showInfo')
+
+    component.saveViewConfig()
+    expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView)
+    expect(toastSpy).toHaveBeenCalledWith(
+      `View "${view.name}" saved successfully.`
+    )
+  })
+
+  it('should support edited view saving as', () => {
+    const view: PaperlessSavedView = {
+      id: 10,
+      name: 'Saved View 10',
+      sort_field: 'added',
+      sort_reverse: true,
+      filter_rules: [
+        {
+          rule_type: FILTER_HAS_TAGS_ANY,
+          value: '20',
+        },
+      ],
+    }
+    jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
+    const queryParams = { view: view.id.toString() }
+    jest
+      .spyOn(activatedRoute, 'queryParamMap', 'get')
+      .mockReturnValue(of(convertToParamMap(queryParams)))
+    activatedRoute.snapshot.queryParams = queryParams
+    router.routerState.snapshot.url = '/view/10/'
+    fixture.detectChanges()
+    expect(documentListService.activeSavedViewId).toEqual(10)
+
+    const modifiedView = Object.assign({}, view)
+    modifiedView.name = 'Foo Bar'
+
+    let openModal: NgbModalRef
+    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
+    const modalSpy = jest.spyOn(modalService, 'open')
+    const toastSpy = jest.spyOn(toastService, 'showInfo')
+    const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
+    savedViewServiceCreate.mockReturnValueOnce(of(modifiedView))
+    component.saveViewConfigAs()
+
+    const modalCloseSpy = jest.spyOn(openModal, 'close')
+    openModal.componentInstance.saveClicked.next({
+      name: 'Foo Bar',
+      show_on_dashboard: true,
+      show_in_sidebar: true,
+    })
+    expect(savedViewServiceCreate).toHaveBeenCalled()
+    expect(modalSpy).toHaveBeenCalled()
+    expect(toastSpy).toHaveBeenCalled()
+    expect(modalCloseSpy).toHaveBeenCalled()
+  })
+
+  it('should handle error on edited view saving as', () => {
+    const view: PaperlessSavedView = {
+      id: 10,
+      name: 'Saved View 10',
+      sort_field: 'added',
+      sort_reverse: true,
+      filter_rules: [
+        {
+          rule_type: FILTER_HAS_TAGS_ANY,
+          value: '20',
+        },
+      ],
+    }
+    jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
+    const queryParams = { view: view.id.toString() }
+    jest
+      .spyOn(activatedRoute, 'queryParamMap', 'get')
+      .mockReturnValue(of(convertToParamMap(queryParams)))
+    activatedRoute.snapshot.queryParams = queryParams
+    router.routerState.snapshot.url = '/view/10/'
+    fixture.detectChanges()
+    expect(documentListService.activeSavedViewId).toEqual(10)
+
+    const modifiedView = Object.assign({}, view)
+    modifiedView.name = 'Foo Bar'
+
+    let openModal: NgbModalRef
+    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
+    jest.spyOn(savedViewService, 'create').mockReturnValueOnce(
+      throwError(
+        () =>
+          new HttpErrorResponse({
+            error: { filter_rules: [{ value: '11' }] },
+          })
+      )
+    )
+    component.saveViewConfigAs()
+
+    openModal.componentInstance.saveClicked.next({
+      name: 'Foo Bar',
+      show_on_dashboard: true,
+      show_in_sidebar: true,
+    })
+    expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] })
+  })
+
+  it('should navigate to a document', () => {
+    fixture.detectChanges()
+    const routerSpy = jest.spyOn(router, 'navigate')
+    component.openDocumentDetail({ id: 99 })
+    expect(routerSpy).toHaveBeenCalledWith(['documents', 99])
+  })
+
+  it('should support checking if notes enabled to hide column', () => {
+    jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
+    fixture.detectChanges()
+    expect(documentListService.sortField).toEqual('created')
+
+    const detailsDisplayModeButton = fixture.debugElement.query(
+      By.css('input[type="radio"]')
+    )
+    detailsDisplayModeButton.nativeElement.checked = true
+    detailsDisplayModeButton.triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.displayMode).toEqual('details')
+
+    expect(
+      fixture.debugElement.queryAll(By.directive(SortableDirective))
+    ).toHaveLength(9)
+
+    expect(component.notesEnabled).toBeTruthy()
+    settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
+    fixture.detectChanges()
+    expect(component.notesEnabled).toBeFalsy()
+    expect(
+      fixture.debugElement.queryAll(By.directive(SortableDirective))
+    ).toHaveLength(8)
+  })
+
+  it('should support toggle on document objects', () => {
+    // TODO: this is just for coverage atm
+    fixture.detectChanges()
+    component.clickTag(1)
+    component.clickCorrespondent(2)
+    component.clickDocumentType(3)
+    component.clickStoragePath(4)
+  })
+
+  it('should support quick filter on document more like', () => {
+    fixture.detectChanges()
+    const qfSpy = jest.spyOn(documentListService, 'quickFilter')
+    component.clickMoreLike(99)
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_FULLTEXT_MORELIKE, value: '99' },
+    ])
+  })
+})
index 3ffee2efaad3e663ad6841984ef4d66341b683cc..32431167be800e512be6890cd947534b5e2a560a 100644 (file)
@@ -9,11 +9,11 @@ import {
 import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
+import { FilterRule } from 'src/app/data/filter-rule'
 import {
-  FilterRule,
   filterRulesDiffer,
   isFullTextFilterRule,
-} from 'src/app/data/filter-rule'
+} from 'src/app/utils/filter-rules'
 import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
 import { PaperlessDocument } from 'src/app/data/paperless-document'
 import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
new file mode 100644 (file)
index 0000000..e499a36
--- /dev/null
@@ -0,0 +1,1672 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import {
+  ComponentFixture,
+  fakeAsync,
+  TestBed,
+  tick,
+} from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { By } from '@angular/platform-browser'
+import { RouterTestingModule } from '@angular/router/testing'
+import {
+  NgbDropdownModule,
+  NgbDatepickerModule,
+  NgbDropdownItem,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectComponent } from '@ng-select/ng-select'
+import { of } from 'rxjs'
+import {
+  FILTER_TITLE,
+  FILTER_TITLE_CONTENT,
+  FILTER_ASN,
+  FILTER_ASN_ISNULL,
+  FILTER_ASN_GT,
+  FILTER_ASN_LT,
+  FILTER_FULLTEXT_QUERY,
+  FILTER_FULLTEXT_MORELIKE,
+  FILTER_CREATED_AFTER,
+  FILTER_CREATED_BEFORE,
+  FILTER_ADDED_AFTER,
+  FILTER_ADDED_BEFORE,
+  FILTER_HAS_TAGS_ALL,
+  FILTER_HAS_TAGS_ANY,
+  FILTER_HAS_ANY_TAG,
+  FILTER_DOES_NOT_HAVE_TAG,
+  FILTER_CORRESPONDENT,
+  FILTER_HAS_CORRESPONDENT_ANY,
+  FILTER_DOES_NOT_HAVE_CORRESPONDENT,
+  FILTER_DOCUMENT_TYPE,
+  FILTER_HAS_DOCUMENT_TYPE_ANY,
+  FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
+  FILTER_STORAGE_PATH,
+  FILTER_HAS_STORAGE_PATH_ANY,
+  FILTER_DOES_NOT_HAVE_STORAGE_PATH,
+  FILTER_OWNER,
+  FILTER_OWNER_ANY,
+  FILTER_OWNER_DOES_NOT_INCLUDE,
+  FILTER_OWNER_ISNULL,
+} from 'src/app/data/filter-rule-type'
+import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
+import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
+import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+import { PaperlessUser } from 'src/app/data/paperless-user'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { FilterPipe } from 'src/app/pipes/filter.pipe'
+import { CorrespondentService } from 'src/app/services/rest/correspondent.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 { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
+import { DateDropdownComponent } from '../../common/date-dropdown/date-dropdown.component'
+import {
+  FilterableDropdownComponent,
+  LogicalOperator,
+  Intersection,
+} from '../../common/filterable-dropdown/filterable-dropdown.component'
+import { ToggleableDropdownButtonComponent } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
+import {
+  PermissionsFilterDropdownComponent,
+  OwnerFilterType,
+} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
+import { FilterEditorComponent } from './filter-editor.component'
+
+const tags: PaperlessTag[] = [
+  {
+    id: 2,
+    name: 'Tag2',
+  },
+  {
+    id: 3,
+    name: 'Tag3',
+  },
+]
+
+const correspondents: PaperlessCorrespondent[] = [
+  {
+    id: 12,
+    name: 'Corresp12',
+  },
+  {
+    id: 13,
+    name: 'Corresp13',
+  },
+]
+
+const document_types: PaperlessDocumentType[] = [
+  {
+    id: 22,
+    name: 'DocType22',
+  },
+  {
+    id: 23,
+    name: 'DocType23',
+  },
+]
+
+const storage_paths: PaperlessStoragePath[] = [
+  {
+    id: 32,
+    name: 'StoragePath32',
+  },
+  {
+    id: 33,
+    name: 'StoragePath33',
+  },
+]
+
+const users: PaperlessUser[] = [
+  {
+    id: 1,
+    username: 'user1',
+  },
+]
+
+describe('FilterEditorComponent', () => {
+  let component: FilterEditorComponent
+  let fixture: ComponentFixture<FilterEditorComponent>
+  let documentService: DocumentService
+  let settingsService: SettingsService
+
+  beforeEach(fakeAsync(() => {
+    TestBed.configureTestingModule({
+      declarations: [
+        FilterEditorComponent,
+        FilterableDropdownComponent,
+        PermissionsFilterDropdownComponent,
+        FilterPipe,
+        IfPermissionsDirective,
+        ClearableBadgeComponent,
+        ToggleableDropdownButtonComponent,
+        DateDropdownComponent,
+        CustomDatePipe,
+      ],
+      providers: [
+        FilterPipe,
+        CustomDatePipe,
+        DatePipe,
+        {
+          provide: TagService,
+          useValue: {
+            listAll: () => of({ results: tags }),
+          },
+        },
+        {
+          provide: CorrespondentService,
+          useValue: {
+            listAll: () => of({ results: correspondents }),
+          },
+        },
+        {
+          provide: DocumentTypeService,
+          useValue: {
+            listAll: () => of({ results: document_types }),
+          },
+        },
+        {
+          provide: StoragePathService,
+          useValue: {
+            listAll: () => of({ results: storage_paths }),
+          },
+        },
+        {
+          provide: UserService,
+          useValue: {
+            listAll: () => of({ results: users }),
+          },
+        },
+        SettingsService,
+      ],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule,
+        NgbDropdownModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbDatepickerModule,
+      ],
+    }).compileComponents()
+
+    documentService = TestBed.inject(DocumentService)
+    settingsService = TestBed.inject(SettingsService)
+    settingsService.currentUser = users[0]
+    fixture = TestBed.createComponent(FilterEditorComponent)
+    component = fixture.componentInstance
+    component.filterRules = []
+    fixture.detectChanges()
+    tick()
+  }))
+
+  // SET filterRules
+
+  it('should ingest text filter rules for doc title', fakeAsync(() => {
+    expect(component.textFilter).toEqual(null)
+    component.filterRules = [
+      {
+        rule_type: FILTER_TITLE,
+        value: 'foo',
+      },
+    ]
+    expect(component.textFilter).toEqual('foo')
+    expect(component.textFilterTarget).toEqual('title') // TEXT_FILTER_TARGET_TITLE
+  }))
+
+  it('should ingest text filter rules for doc title + content', fakeAsync(() => {
+    expect(component.textFilter).toEqual(null)
+    component.filterRules = [
+      {
+        rule_type: FILTER_TITLE_CONTENT,
+        value: 'foo',
+      },
+    ]
+    expect(component.textFilter).toEqual('foo')
+    expect(component.textFilterTarget).toEqual('title-content') // TEXT_FILTER_TARGET_TITLE_CONTENT
+  }))
+
+  it('should ingest text filter rules for doc asn', fakeAsync(() => {
+    expect(component.textFilter).toEqual(null)
+    component.filterRules = [
+      {
+        rule_type: FILTER_ASN,
+        value: 'foo',
+      },
+    ]
+    expect(component.textFilter).toEqual('foo')
+    expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
+  }))
+
+  it('should ingest text filter rules for doc asn is null', fakeAsync(() => {
+    expect(component.textFilterTarget).toEqual('title-content')
+    expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
+    component.filterRules = [
+      {
+        rule_type: FILTER_ASN_ISNULL,
+        value: 'true',
+      },
+    ]
+    expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
+    expect(component.textFilterModifier).toEqual('is null') // TEXT_FILTER_MODIFIER_NULL
+  }))
+
+  it('should ingest text filter rules for doc asn is not null', fakeAsync(() => {
+    expect(component.textFilterTarget).toEqual('title-content')
+    expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
+    component.filterRules = [
+      {
+        rule_type: FILTER_ASN_ISNULL,
+        value: 'false',
+      },
+    ]
+    expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
+    expect(component.textFilterModifier).toEqual('not null') // TEXT_FILTER_MODIFIER_NOTNULL
+  }))
+
+  it('should ingest text filter rules for doc asn greater than', fakeAsync(() => {
+    expect(component.textFilterTarget).toEqual('title-content')
+    expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
+    component.filterRules = [
+      {
+        rule_type: FILTER_ASN_GT,
+        value: '0',
+      },
+    ]
+    expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
+    expect(component.textFilterModifier).toEqual('greater') // TEXT_FILTER_MODIFIER_GT
+  }))
+
+  it('should ingest text filter rules for doc asn less than', fakeAsync(() => {
+    expect(component.textFilterTarget).toEqual('title-content')
+    expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
+    component.filterRules = [
+      {
+        rule_type: FILTER_ASN_LT,
+        value: '1000000',
+      },
+    ]
+    expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
+    expect(component.textFilterModifier).toEqual('less') // TEXT_FILTER_MODIFIER_LT
+  }))
+
+  it('should ingest text filter rules for fulltext query', fakeAsync(() => {
+    expect(component.textFilter).toEqual(null)
+    component.filterRules = [
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'foo,bar',
+      },
+    ]
+    expect(component.textFilter).toEqual('foo,bar')
+    expect(component.textFilterTarget).toEqual('fulltext-query') // TEXT_FILTER_TARGET_FULLTEXT_QUERY
+  }))
+
+  it('should ingest text filter rules for fulltext query that include date created', fakeAsync(() => {
+    expect(component.dateCreatedRelativeDate).toBeNull()
+    component.filterRules = [
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'created:[-1 week to now]',
+      },
+    ]
+    expect(component.dateCreatedRelativeDate).toEqual(0) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
+    expect(component.textFilter).toBeNull()
+  }))
+
+  it('should ingest text filter rules for fulltext query that include date added', fakeAsync(() => {
+    expect(component.dateAddedRelativeDate).toBeNull()
+    component.filterRules = [
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'added:[-1 week to now]',
+      },
+    ]
+    expect(component.dateAddedRelativeDate).toEqual(0) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
+    expect(component.textFilter).toBeNull()
+  }))
+
+  it('should ingest text filter rules for more like', fakeAsync(() => {
+    const moreLikeSpy = jest.spyOn(documentService, 'get')
+    moreLikeSpy.mockReturnValue(of({ id: 1, title: 'Foo Bar' }))
+    expect(component.textFilter).toEqual(null)
+    component.filterRules = [
+      {
+        rule_type: FILTER_FULLTEXT_MORELIKE,
+        value: '1',
+      },
+    ]
+    expect(component.textFilterTarget).toEqual('fulltext-morelike') // TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
+    expect(moreLikeSpy).toHaveBeenCalledWith(1)
+    expect(component.textFilter).toEqual('Foo Bar')
+    // we have to do this here because it cant be done by user input
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_FULLTEXT_MORELIKE,
+        value: '1',
+      },
+    ])
+  }))
+
+  it('should ingest filter rules for date created after', fakeAsync(() => {
+    expect(component.dateCreatedAfter).toBeNull()
+    component.filterRules = [
+      {
+        rule_type: FILTER_CREATED_AFTER,
+        value: '2023-05-14',
+      },
+    ]
+    expect(component.dateCreatedAfter).toEqual('2023-05-14')
+  }))
+
+  it('should ingest filter rules for date created before', fakeAsync(() => {
+    expect(component.dateCreatedBefore).toBeNull()
+    component.filterRules = [
+      {
+        rule_type: FILTER_CREATED_BEFORE,
+        value: '2023-05-14',
+      },
+    ]
+    expect(component.dateCreatedBefore).toEqual('2023-05-14')
+  }))
+
+  it('should ingest filter rules for date added after', fakeAsync(() => {
+    expect(component.dateAddedAfter).toBeNull()
+    component.filterRules = [
+      {
+        rule_type: FILTER_ADDED_AFTER,
+        value: '2023-05-14',
+      },
+    ]
+    expect(component.dateAddedAfter).toEqual('2023-05-14')
+  }))
+
+  it('should ingest filter rules for date added before', fakeAsync(() => {
+    expect(component.dateAddedBefore).toBeNull()
+    component.filterRules = [
+      {
+        rule_type: FILTER_ADDED_BEFORE,
+        value: '2023-05-14',
+      },
+    ]
+    expect(component.dateAddedBefore).toEqual('2023-05-14')
+  }))
+
+  it('should ingest filter rules for has all tags', fakeAsync(() => {
+    expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: '2',
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: '3',
+      },
+    ]
+    expect(component.tagSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.And
+    )
+    expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: null,
+      },
+    ]
+    component.toggleTag(2) // coverage
+  }))
+
+  it('should ingest filter rules for has any tags', fakeAsync(() => {
+    expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '2',
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '3',
+      },
+    ]
+    expect(component.tagSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.Or
+    )
+    expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for has any tag', fakeAsync(() => {
+    expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_ANY_TAG,
+        value: '1',
+      },
+    ]
+    expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(1)
+    expect(component.tagSelectionModel.get(null)).toBeTruthy()
+  }))
+
+  it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
+    expect(component.tagSelectionModel.getExcludedItems()).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_TAG,
+        value: '2',
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_TAG,
+        value: '3',
+      },
+    ]
+    expect(component.tagSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.And
+    )
+    expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_TAG,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for has correspondent', fakeAsync(() => {
+    expect(
+      component.correspondentSelectionModel.getSelectedItems()
+    ).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_CORRESPONDENT,
+        value: '12',
+      },
+    ]
+    expect(component.correspondentSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.Or
+    )
+    expect(component.correspondentSelectionModel.intersection).toEqual(
+      Intersection.Include
+    )
+    expect(component.correspondentSelectionModel.getSelectedItems()).toEqual([
+      correspondents[0],
+    ])
+    component.toggleCorrespondent(12) // coverage
+  }))
+
+  it('should ingest filter rules for has any of correspondents', fakeAsync(() => {
+    expect(
+      component.correspondentSelectionModel.getSelectedItems()
+    ).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_CORRESPONDENT_ANY,
+        value: '12',
+      },
+      {
+        rule_type: FILTER_HAS_CORRESPONDENT_ANY,
+        value: '13',
+      },
+    ]
+    expect(component.correspondentSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.Or
+    )
+    expect(component.correspondentSelectionModel.intersection).toEqual(
+      Intersection.Include
+    )
+    expect(component.correspondentSelectionModel.getSelectedItems()).toEqual(
+      correspondents
+    )
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_CORRESPONDENT_ANY,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for does not have any of correspondents', fakeAsync(() => {
+    expect(
+      component.correspondentSelectionModel.getExcludedItems()
+    ).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
+        value: '12',
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
+        value: '13',
+      },
+    ]
+    expect(component.correspondentSelectionModel.intersection).toEqual(
+      Intersection.Exclude
+    )
+    expect(component.correspondentSelectionModel.getExcludedItems()).toEqual(
+      correspondents
+    )
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for has document type', fakeAsync(() => {
+    expect(
+      component.documentTypeSelectionModel.getSelectedItems()
+    ).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOCUMENT_TYPE,
+        value: '22',
+      },
+    ]
+    expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.Or
+    )
+    expect(component.documentTypeSelectionModel.intersection).toEqual(
+      Intersection.Include
+    )
+    expect(component.documentTypeSelectionModel.getSelectedItems()).toEqual([
+      document_types[0],
+    ])
+    component.toggleDocumentType(22) // coverage
+  }))
+
+  it('should ingest filter rules for has any of document types', fakeAsync(() => {
+    expect(
+      component.documentTypeSelectionModel.getSelectedItems()
+    ).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
+        value: '22',
+      },
+      {
+        rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
+        value: '23',
+      },
+    ]
+    expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.Or
+    )
+    expect(component.documentTypeSelectionModel.intersection).toEqual(
+      Intersection.Include
+    )
+    expect(component.documentTypeSelectionModel.getSelectedItems()).toEqual(
+      document_types
+    )
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for does not have any of document types', fakeAsync(() => {
+    expect(
+      component.documentTypeSelectionModel.getExcludedItems()
+    ).toHaveLength(0)
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
+        value: '22',
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
+        value: '23',
+      },
+    ]
+    expect(component.documentTypeSelectionModel.intersection).toEqual(
+      Intersection.Exclude
+    )
+    expect(component.documentTypeSelectionModel.getExcludedItems()).toEqual(
+      document_types
+    )
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for has storage path', fakeAsync(() => {
+    expect(component.storagePathSelectionModel.getSelectedItems()).toHaveLength(
+      0
+    )
+    component.filterRules = [
+      {
+        rule_type: FILTER_STORAGE_PATH,
+        value: '32',
+      },
+    ]
+    expect(component.storagePathSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.Or
+    )
+    expect(component.storagePathSelectionModel.intersection).toEqual(
+      Intersection.Include
+    )
+    expect(component.storagePathSelectionModel.getSelectedItems()).toEqual([
+      storage_paths[0],
+    ])
+    component.toggleStoragePath(32) // coverage
+  }))
+
+  it('should ingest filter rules for has any of storage paths', fakeAsync(() => {
+    expect(component.storagePathSelectionModel.getSelectedItems()).toHaveLength(
+      0
+    )
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_STORAGE_PATH_ANY,
+        value: '32',
+      },
+      {
+        rule_type: FILTER_HAS_STORAGE_PATH_ANY,
+        value: '33',
+      },
+    ]
+    expect(component.storagePathSelectionModel.logicalOperator).toEqual(
+      LogicalOperator.Or
+    )
+    expect(component.storagePathSelectionModel.intersection).toEqual(
+      Intersection.Include
+    )
+    expect(component.storagePathSelectionModel.getSelectedItems()).toEqual(
+      storage_paths
+    )
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_STORAGE_PATH_ANY,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for does not have any of storage paths', fakeAsync(() => {
+    expect(component.storagePathSelectionModel.getExcludedItems()).toHaveLength(
+      0
+    )
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
+        value: '32',
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
+        value: '33',
+      },
+    ]
+    expect(component.storagePathSelectionModel.intersection).toEqual(
+      Intersection.Exclude
+    )
+    expect(component.storagePathSelectionModel.getExcludedItems()).toEqual(
+      storage_paths
+    )
+    // coverage
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
+        value: null,
+      },
+    ]
+  }))
+
+  it('should ingest filter rules for owner', fakeAsync(() => {
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.NONE
+    )
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER,
+        value: '100',
+      },
+    ]
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.SELF
+    )
+    expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
+    expect(component.permissionsSelectionModel.userID).toEqual(100)
+  }))
+
+  it('should ingest filter rules for owner is others', fakeAsync(() => {
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.NONE
+    )
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER_ANY,
+        value: '50',
+      },
+    ]
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.OTHERS
+    )
+    expect(component.permissionsSelectionModel.includeUsers).toContain(50)
+  }))
+
+  it('should ingest filter rules for owner does not include others', fakeAsync(() => {
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.NONE
+    )
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
+        value: '50',
+      },
+    ]
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.NOT_SELF
+    )
+    expect(component.permissionsSelectionModel.excludeUsers).toContain(50)
+  }))
+
+  it('should ingest filter rules for owner is null', fakeAsync(() => {
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.NONE
+    )
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER_ISNULL,
+        value: 'true',
+      },
+    ]
+    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
+      OwnerFilterType.UNOWNED
+    )
+    expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
+  }))
+
+  it('should ingest filter rules for owner is not null', fakeAsync(() => {
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER_ISNULL,
+        value: 'false',
+      },
+    ]
+    expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER_ISNULL,
+        value: '0',
+      },
+    ]
+    expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
+  }))
+
+  // GET filterRules
+
+  it('should convert user input to correct filter rules on text field search title + content', fakeAsync(() => {
+    component.textFilterInput.nativeElement.value = 'foo'
+    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    fixture.detectChanges()
+    tick(400) // debounce time
+    expect(component.textFilter).toEqual('foo')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_TITLE_CONTENT,
+        value: 'foo',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on text field search title only', fakeAsync(() => {
+    component.textFilterInput.nativeElement.value = 'foo'
+    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    const textFieldTargetDropdown = fixture.debugElement.query(
+      By.directive(NgbDropdownItem)
+    )
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_TITLE
+    fixture.detectChanges()
+    tick(400) // debounce time
+    expect(component.textFilter).toEqual('foo')
+    expect(component.textFilterTarget).toEqual('title')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_TITLE,
+        value: 'foo',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on text field search equals asn', fakeAsync(() => {
+    component.textFilterInput.nativeElement.value = '1234'
+    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    const textFieldTargetDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )[2]
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
+    fixture.detectChanges()
+    tick(400) // debounce time
+    expect(component.textFilterTarget).toEqual('asn')
+    expect(component.textFilterModifier).toEqual('equals')
+    expect(component.textFilter).toEqual('1234')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_ASN,
+        value: '1234',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on text field search greater than asn', fakeAsync(() => {
+    component.textFilterInput.nativeElement.value = '123'
+    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    const textFieldTargetDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )[2]
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
+    fixture.detectChanges()
+    tick(400) // debounce time
+    const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
+    textFieldModifierSelect.nativeElement.value = 'greater' // TEXT_FILTER_MODIFIER_GT
+    textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
+    fixture.detectChanges()
+    expect(component.textFilterTarget).toEqual('asn')
+    expect(component.textFilterModifier).toEqual('greater')
+    expect(component.textFilter).toEqual('123')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_ASN_GT,
+        value: '123',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on text field search less than asn', fakeAsync(() => {
+    component.textFilterInput.nativeElement.value = '999'
+    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    const textFieldTargetDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )[2]
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
+    fixture.detectChanges()
+    tick(400) // debounce time
+    const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
+    textFieldModifierSelect.nativeElement.value = 'less' // TEXT_FILTER_MODIFIER_LT
+    textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
+    fixture.detectChanges()
+    expect(component.textFilterTarget).toEqual('asn')
+    expect(component.textFilterModifier).toEqual('less')
+    expect(component.textFilter).toEqual('999')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_ASN_LT,
+        value: '999',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on asn is null', fakeAsync(() => {
+    const textFieldTargetDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )[2]
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
+    fixture.detectChanges()
+    const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
+    textFieldModifierSelect.nativeElement.value = 'is null' // TEXT_FILTER_MODIFIER_LT
+    textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
+    fixture.detectChanges()
+    expect(component.textFilterTarget).toEqual('asn')
+    expect(component.textFilterModifier).toEqual('is null')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_ASN_ISNULL,
+        value: 'true',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on asn is not null', fakeAsync(() => {
+    const textFieldTargetDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )[2]
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
+    fixture.detectChanges()
+    const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
+    textFieldModifierSelect.nativeElement.value = 'not null' // TEXT_FILTER_MODIFIER_LT
+    textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
+    fixture.detectChanges()
+    expect(component.textFilterTarget).toEqual('asn')
+    expect(component.textFilterModifier).toEqual('not null')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_ASN_ISNULL,
+        value: 'false',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
+    component.textFilterInput.nativeElement.value = 'foo'
+    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    const textFieldTargetDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )[3]
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
+    fixture.detectChanges()
+    tick(400)
+    expect(component.textFilterTarget).toEqual('fulltext-query')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'foo',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on tag select not assigned', fakeAsync(() => {
+    const tagsFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[0]
+    tagsFilterableDropdown.triggerEventHandler('opened')
+    const tagButton = tagsFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )[0]
+    tagButton.triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_ANY_TAG,
+        value: 'false',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on tag selections', fakeAsync(() => {
+    const tagsFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[0] // Tags dropdown
+    tagsFilterableDropdown.triggerEventHandler('opened')
+    const tagButtons = tagsFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )
+    tagButtons[1].triggerEventHandler('toggle')
+    tagButtons[2].triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: tags[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: tags[1].id.toString(),
+      },
+    ])
+    const toggleOperatorButtons = tagsFilterableDropdown.queryAll(
+      By.css('input[type=radio]')
+    )
+    toggleOperatorButtons[1].nativeElement.checked = true
+    toggleOperatorButtons[1].triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: tags[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: tags[1].id.toString(),
+      },
+    ])
+    tagButtons[2].triggerEventHandler('exclude')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: tags[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_TAG,
+        value: tags[1].id.toString(),
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on correspondent selections', fakeAsync(() => {
+    const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[1] // Corresp dropdown
+    correspondentsFilterableDropdown.triggerEventHandler('opened')
+    const correspondentButtons = correspondentsFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )
+    correspondentButtons[1].triggerEventHandler('toggle')
+    correspondentButtons[2].triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_CORRESPONDENT_ANY,
+        value: correspondents[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_HAS_CORRESPONDENT_ANY,
+        value: correspondents[1].id.toString(),
+      },
+    ])
+    const toggleIntersectionButtons = correspondentsFilterableDropdown.queryAll(
+      By.css('input[type=radio]')
+    )
+    toggleIntersectionButtons[1].nativeElement.checked = true
+    toggleIntersectionButtons[1].triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
+        value: correspondents[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
+        value: correspondents[1].id.toString(),
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on correspondent select not assigned', fakeAsync(() => {
+    const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[1]
+    correspondentsFilterableDropdown.triggerEventHandler('opened')
+    const notAssignedButton = correspondentsFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )[0]
+    notAssignedButton.triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_CORRESPONDENT,
+        value: null,
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on document type selections', fakeAsync(() => {
+    const documentTypesFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[2] // DocType dropdown
+    documentTypesFilterableDropdown.triggerEventHandler('opened')
+    const documentTypeButtons = documentTypesFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )
+    documentTypeButtons[1].triggerEventHandler('toggle')
+    documentTypeButtons[2].triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
+        value: document_types[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
+        value: document_types[1].id.toString(),
+      },
+    ])
+    const toggleIntersectionButtons = documentTypesFilterableDropdown.queryAll(
+      By.css('input[type=radio]')
+    )
+    toggleIntersectionButtons[1].nativeElement.checked = true
+    toggleIntersectionButtons[1].triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
+        value: document_types[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
+        value: document_types[1].id.toString(),
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on doc type select not assigned', fakeAsync(() => {
+    const docTypesFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[2]
+    docTypesFilterableDropdown.triggerEventHandler('opened')
+    const notAssignedButton = docTypesFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )[0]
+    notAssignedButton.triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_DOCUMENT_TYPE,
+        value: null,
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on storage path selections', fakeAsync(() => {
+    const storagePathFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[3] // StoragePath dropdown
+    storagePathFilterableDropdown.triggerEventHandler('opened')
+    const storagePathButtons = storagePathFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )
+    storagePathButtons[1].triggerEventHandler('toggle')
+    storagePathButtons[2].triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_STORAGE_PATH_ANY,
+        value: storage_paths[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_HAS_STORAGE_PATH_ANY,
+        value: storage_paths[1].id.toString(),
+      },
+    ])
+    const toggleIntersectionButtons = storagePathFilterableDropdown.queryAll(
+      By.css('input[type=radio]')
+    )
+    toggleIntersectionButtons[1].nativeElement.checked = true
+    toggleIntersectionButtons[1].triggerEventHandler('change')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
+        value: storage_paths[0].id.toString(),
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
+        value: storage_paths[1].id.toString(),
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on storage path select not assigned', fakeAsync(() => {
+    const storagePathsFilterableDropdown = fixture.debugElement.queryAll(
+      By.directive(FilterableDropdownComponent)
+    )[3]
+    storagePathsFilterableDropdown.triggerEventHandler('opened')
+    const notAssignedButton = storagePathsFilterableDropdown.queryAll(
+      By.directive(ToggleableDropdownButtonComponent)
+    )[0]
+    notAssignedButton.triggerEventHandler('toggle')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_STORAGE_PATH,
+        value: null,
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
+    const dateCreatedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[0]
+    const dateCreatedAfter = dateCreatedDropdown.queryAll(By.css('input'))[0]
+
+    dateCreatedAfter.nativeElement.value = '05/14/2023'
+    // dateCreatedAfter.triggerEventHandler('change')
+    // TODO: why isnt ngModel triggering this on change?
+    component.dateCreatedAfter = '2023-05-14'
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_CREATED_AFTER,
+        value: '2023-05-14',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on date created before', fakeAsync(() => {
+    const dateCreatedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[0]
+    const dateCreatedBefore = dateCreatedDropdown.queryAll(By.css('input'))[1]
+
+    dateCreatedBefore.nativeElement.value = '05/14/2023'
+    // dateCreatedBefore.triggerEventHandler('change')
+    // TODO: why isnt ngModel triggering this on change?
+    component.dateCreatedBefore = '2023-05-14'
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_CREATED_BEFORE,
+        value: '2023-05-14',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on date created with relative date', fakeAsync(() => {
+    const dateCreatedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[0]
+    const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
+      By.css('button')
+    )[1]
+    dateCreatedBeforeRelativeButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'created:[-1 week to now]',
+      },
+    ])
+  }))
+
+  it('should carry over text filtering on date created with relative date', fakeAsync(() => {
+    component.textFilter = 'foo'
+    const dateCreatedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[0]
+    const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
+      By.css('button')
+    )[1]
+    dateCreatedBeforeRelativeButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'foo,created:[-1 week to now]',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
+    const dateAddedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[1]
+    const dateAddedAfter = dateAddedDropdown.queryAll(By.css('input'))[0]
+
+    dateAddedAfter.nativeElement.value = '05/14/2023'
+    // dateAddedAfter.triggerEventHandler('change')
+    // TODO: why isnt ngModel triggering this on change?
+    component.dateAddedAfter = '2023-05-14'
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_ADDED_AFTER,
+        value: '2023-05-14',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on date added before', fakeAsync(() => {
+    const dateAddedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[1]
+    const dateAddedBefore = dateAddedDropdown.queryAll(By.css('input'))[1]
+
+    dateAddedBefore.nativeElement.value = '05/14/2023'
+    // dateAddedBefore.triggerEventHandler('change')
+    // TODO: why isnt ngModel triggering this on change?
+    component.dateAddedBefore = '2023-05-14'
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_ADDED_BEFORE,
+        value: '2023-05-14',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on date added with relative date', fakeAsync(() => {
+    const dateAddedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[1]
+    const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
+      By.css('button')
+    )[1]
+    dateAddedBeforeRelativeButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'added:[-1 week to now]',
+      },
+    ])
+  }))
+
+  it('should carry over text filtering on date added with relative date', fakeAsync(() => {
+    component.textFilter = 'foo'
+    const dateAddedDropdown = fixture.debugElement.queryAll(
+      By.directive(DateDropdownComponent)
+    )[1]
+    const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
+      By.css('button')
+    )[1]
+    dateAddedBeforeRelativeButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'foo,added:[-1 week to now]',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter on permissions select my docs', fakeAsync(() => {
+    const permissionsDropdown = fixture.debugElement.query(
+      By.directive(PermissionsFilterDropdownComponent)
+    )
+    const myDocsButton = permissionsDropdown.queryAll(By.css('button'))[2]
+    myDocsButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    tick(400)
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_OWNER,
+        value: '1',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter on permissions select shared with me', fakeAsync(() => {
+    const permissionsDropdown = fixture.debugElement.query(
+      By.directive(PermissionsFilterDropdownComponent)
+    )
+    const sharedWithMe = permissionsDropdown.queryAll(By.css('button'))[3]
+    sharedWithMe.triggerEventHandler('click')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
+        value: '1',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter on permissions select shared with me', fakeAsync(() => {
+    const permissionsDropdown = fixture.debugElement.query(
+      By.directive(PermissionsFilterDropdownComponent)
+    )
+    const sharedWithMeButton = permissionsDropdown.queryAll(By.css('button'))[3]
+    sharedWithMeButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
+        value: '1',
+      },
+    ])
+    component.permissionsSelectionModel.excludeUsers.push(2)
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
+        value: '1,2',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter on permissions select unowned', fakeAsync(() => {
+    const permissionsDropdown = fixture.debugElement.query(
+      By.directive(PermissionsFilterDropdownComponent)
+    )
+    const unownedButton = permissionsDropdown.queryAll(By.css('button'))[4]
+    unownedButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_OWNER_ISNULL,
+        value: 'true',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter on permissions select others', fakeAsync(() => {
+    const permissionsDropdown = fixture.debugElement.query(
+      By.directive(PermissionsFilterDropdownComponent)
+    )
+    const userSelect = permissionsDropdown.query(
+      By.directive(NgSelectComponent)
+    )
+    // TODO: mock input in code
+    // userSelect.query(By.css('input')).nativeElement.value = '3'
+    // userSelect.triggerEventHandler('change')
+    component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
+    component.permissionsSelectionModel.includeUsers.push(3)
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_OWNER_ANY,
+        value: '3',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter on permissions hide unowned', fakeAsync(() => {
+    const permissionsDropdown = fixture.debugElement.query(
+      By.directive(PermissionsFilterDropdownComponent)
+    )
+    const ownerToggle = permissionsDropdown.query(
+      By.css('input[type=checkbox]')
+    )
+    ownerToggle.nativeElement.checked = true
+    // ownerToggle.triggerEventHandler('change')
+    // TODO: ngModel isnt doing this here
+    component.permissionsSelectionModel.hideUnowned = true
+    fixture.detectChanges()
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_OWNER_ISNULL,
+        value: 'false',
+      },
+    ])
+  }))
+
+  // The rest
+
+  it('should support setting selection data', () => {
+    component.selectionData = null
+    component.selectionData = {
+      selected_storage_paths: [
+        { id: 2, document_count: 1 },
+        { id: 3, document_count: 0 },
+      ],
+      selected_correspondents: [
+        { id: 12, document_count: 1 },
+        { id: 13, document_count: 0 },
+      ],
+      selected_tags: [
+        { id: 22, document_count: 1 },
+        { id: 23, document_count: 0 },
+      ],
+      selected_document_types: [
+        { id: 32, document_count: 1 },
+        { id: 33, document_count: 0 },
+      ],
+    }
+  })
+
+  it('should generate filter names', () => {
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_CORRESPONDENT_ANY,
+        value: '12',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual(
+      `Correspondent: ${correspondents[0].name}`
+    )
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_CORRESPONDENT,
+        value: null,
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Without correspondent')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
+        value: '22',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual(
+      `Document type: ${document_types[0].name}`
+    )
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_DOCUMENT_TYPE,
+        value: null,
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Without document type')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_STORAGE_PATH_ANY,
+        value: '32',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual(
+      `Storage path: ${storage_paths[0].name}`
+    )
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_STORAGE_PATH,
+        value: null,
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Without storage path')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: '2',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual(`Tag: ${tags[0].name}`)
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_ANY_TAG,
+        value: 'false',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Without any tag')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_TITLE,
+        value: 'foo',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Title: foo')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_ASN,
+        value: '1234',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('ASN: 1234')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER,
+        value: '1',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Owner: 1')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
+        value: '1',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Owner not in: 1')
+
+    component.filterRules = [
+      {
+        rule_type: FILTER_OWNER_ISNULL,
+        value: 'true',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('Without an owner')
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '2',
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '3',
+      },
+    ]
+    expect(component.generateFilterName()).toEqual('')
+  })
+
+  it('should support resetting filter rules', () => {
+    const rules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '2',
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '3',
+      },
+    ]
+    component.unmodifiedFilterRules = rules
+    component.filterRules = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: '2',
+      },
+      {
+        rule_type: FILTER_DOES_NOT_HAVE_TAG,
+        value: '3',
+      },
+    ]
+    component.resetSelected()
+    expect(component.filterRules).toEqual(rules)
+  })
+
+  it('should support resetting text field', () => {
+    component.textFilter = 'foo'
+    component.resetTextField()
+    expect(component.textFilter).toEqual('')
+  })
+
+  it('should support Enter / Esc key on text field', () => {
+    component.textFilterInput.nativeElement.value = 'foo'
+    component.textFilterInput.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Enter' })
+    )
+    expect(component.textFilter).toEqual('foo')
+    component.textFilterInput.nativeElement.value = 'foo bar'
+    component.textFilterInput.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Escape' })
+    )
+    expect(component.textFilter).toEqual('')
+  })
+})
index 37a58c54c51119a5eeca6030af812f060a21014f..64d237f2ccb3cbb336eddf268b8cc25114d68b90 100644 (file)
@@ -16,7 +16,8 @@ import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'
 import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
 import { TagService } from 'src/app/services/rest/tag.service'
 import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
-import { filterRulesDiffer, FilterRule } from 'src/app/data/filter-rule'
+import { FilterRule } from 'src/app/data/filter-rule'
+import { filterRulesDiffer } from 'src/app/utils/filter-rules'
 import {
   FILTER_ADDED_AFTER,
   FILTER_ADDED_BEFORE,
@@ -67,7 +68,6 @@ import {
   OwnerFilterType,
   PermissionsSelectionModel,
 } from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
-import { SettingsService } from 'src/app/services/settings.service'
 
 const TEXT_FILTER_TARGET_TITLE = 'title'
 const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@@ -111,7 +111,8 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
   generateFilterName() {
     if (this.filterRules.length == 1) {
       let rule = this.filterRules[0]
-      switch (this.filterRules[0].rule_type) {
+      switch (rule.rule_type) {
+        case FILTER_CORRESPONDENT:
         case FILTER_HAS_CORRESPONDENT_ANY:
           if (rule.value) {
             return $localize`Correspondent: ${
@@ -121,15 +122,26 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
             return $localize`Without correspondent`
           }
 
+        case FILTER_DOCUMENT_TYPE:
         case FILTER_HAS_DOCUMENT_TYPE_ANY:
           if (rule.value) {
-            return $localize`Type: ${
+            return $localize`Document type: ${
               this.documentTypes.find((dt) => dt.id == +rule.value)?.name
             }`
           } else {
             return $localize`Without document type`
           }
 
+        case FILTER_STORAGE_PATH:
+        case FILTER_HAS_STORAGE_PATH_ANY:
+          if (rule.value) {
+            return $localize`Storage path: ${
+              this.storagePaths.find((sp) => sp.id == +rule.value)?.name
+            }`
+          } else {
+            return $localize`Without storage path`
+          }
+
         case FILTER_HAS_TAGS_ALL:
           return $localize`Tag: ${
             this.tags.find((t) => t.id == +rule.value)?.name
@@ -165,8 +177,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
     private tagService: TagService,
     private correspondentService: CorrespondentService,
     private documentService: DocumentService,
-    private storagePathService: StoragePathService,
-    private settingsService: SettingsService
+    private storagePathService: StoragePathService
   ) {}
 
   @ViewChild('textFilterInput')
@@ -557,7 +568,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
     ) {
       filterRules.push({
         rule_type: FILTER_FULLTEXT_MORELIKE,
-        value: this._moreLikeId?.toString(),
+        value: this._moreLikeId.toString(),
       })
     }
     if (this.tagSelectionModel.isNoneSelected()) {
diff --git a/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.spec.ts b/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..f1412ce
--- /dev/null
@@ -0,0 +1,89 @@
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
+import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
+import { By } from '@angular/platform-browser'
+import { TextComponent } from '../../common/input/text/text.component'
+import { CheckComponent } from '../../common/input/check/check.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+
+describe('SaveViewConfigDialogComponent', () => {
+  let component: SaveViewConfigDialogComponent
+  let fixture: ComponentFixture<SaveViewConfigDialogComponent>
+  let modal: NgbActiveModal
+
+  beforeEach(fakeAsync(() => {
+    TestBed.configureTestingModule({
+      declarations: [
+        SaveViewConfigDialogComponent,
+        TextComponent,
+        CheckComponent,
+      ],
+      providers: [NgbActiveModal],
+      imports: [NgbModalModule, FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    modal = TestBed.inject(NgbActiveModal)
+    fixture = TestBed.createComponent(SaveViewConfigDialogComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    tick()
+  }))
+
+  it('should support default name', () => {
+    const name = 'Tag: Inbox'
+    let result
+    component.saveClicked.subscribe((saveResult) => (result = saveResult))
+    component.defaultName = name
+    component.save()
+    expect(component.defaultName).toEqual(name)
+    expect(result).toEqual({
+      name,
+      showInSideBar: false,
+      showOnDashboard: false,
+    })
+  })
+
+  it('should support user input', () => {
+    const name = 'Tag: Inbox'
+    let result
+    component.saveClicked.subscribe((saveResult) => (result = saveResult))
+
+    const nameInput = fixture.debugElement
+      .query(By.directive(TextComponent))
+      .query(By.css('input'))
+    nameInput.nativeElement.value = name
+    component.saveViewConfigForm.get('name').patchValue(name) // normally done by angular
+
+    const sidebarCheckInput = fixture.debugElement
+      .queryAll(By.directive(CheckComponent))[0]
+      .query(By.css('input'))
+    sidebarCheckInput.nativeElement.checked = true
+    component.saveViewConfigForm.get('showInSideBar').patchValue(true) // normally done by angular
+
+    const dashboardCheckInput = fixture.debugElement
+      .queryAll(By.directive(CheckComponent))[1]
+      .query(By.css('input'))
+    dashboardCheckInput.nativeElement.checked = true
+    component.saveViewConfigForm.get('showOnDashboard').patchValue(true) // normally done by angular
+
+    component.save()
+    expect(result).toEqual({
+      name,
+      showInSideBar: true,
+      showOnDashboard: true,
+    })
+  })
+
+  it('should support default name', () => {
+    const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
+    const modalCloseSpy = jest.spyOn(modal, 'close')
+    component.cancel()
+    expect(saveClickedSpy).not.toHaveBeenCalled()
+    expect(modalCloseSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/document-notes/document-notes.component.spec.ts b/src-ui/src/app/components/document-notes/document-notes.component.spec.ts
new file mode 100644 (file)
index 0000000..dbe3933
--- /dev/null
@@ -0,0 +1,187 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { DocumentNotesComponent } from './document-notes.component'
+import { UserService } from 'src/app/services/rest/user.service'
+import { of, throwError } from 'rxjs'
+import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { PaperlessDocumentNote } from 'src/app/data/paperless-document-note'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { DatePipe } from '@angular/common'
+import { By } from '@angular/platform-browser'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+
+const notes: PaperlessDocumentNote[] = [
+  {
+    id: 23,
+    note: 'Note 23',
+    user: 1,
+  },
+  {
+    id: 24,
+    note: 'Note 24',
+    user: 1,
+  },
+  {
+    id: 25,
+    note: 'Note 25',
+    user: 2,
+  },
+  {
+    id: 30,
+    note: 'Note 30',
+    user: 3,
+  },
+]
+
+describe('DocumentNotesComponent', () => {
+  let component: DocumentNotesComponent
+  let fixture: ComponentFixture<DocumentNotesComponent>
+  let notesService: DocumentNotesService
+  let toastService: ToastService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DocumentNotesComponent,
+        CustomDatePipe,
+        IfPermissionsDirective,
+      ],
+      providers: [
+        {
+          provide: UserService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 1,
+                    username: 'user1',
+                    first_name: 'User1',
+                    last_name: 'Lastname1',
+                  },
+                  {
+                    id: 2,
+                    username: 'user2',
+                  },
+                  {
+                    id: 3,
+                    username: 'user3',
+                  },
+                ],
+              }),
+          },
+        },
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+          },
+        },
+        CustomDatePipe,
+        DatePipe,
+      ],
+      imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    notesService = TestBed.inject(DocumentNotesService)
+    toastService = TestBed.inject(ToastService)
+    fixture = TestBed.createComponent(DocumentNotesComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should display notes with user name / username', () => {
+    component.notes = notes
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      notes[0].note
+    )
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      notes[1].note
+    )
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      notes[2].note
+    )
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      notes[3].note
+    )
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      'User1 Lastname1'
+    )
+    expect(fixture.debugElement.nativeElement.textContent).toContain('user2')
+    expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
+  })
+
+  it('should handle note user display in all situations', () => {
+    expect(component.displayName({ id: 1, user: 1 })).toEqual(
+      'User1 Lastname1 (user1)'
+    )
+    expect(component.displayName({ id: 1, user: 2 })).toEqual('user2')
+    expect(component.displayName({ id: 1, user: 4 })).toEqual('')
+    expect(component.displayName({ id: 1 })).toEqual('')
+  })
+
+  it('should support note entry, show error if fails', () => {
+    component.documentId = 12
+    const note = 'This is the new note.'
+    const noteTextArea = fixture.debugElement.query(By.css('textarea'))
+    noteTextArea.nativeElement.value = note
+    noteTextArea.nativeElement.dispatchEvent(new Event('input'))
+    fixture.detectChanges()
+    const addSpy = jest.spyOn(notesService, 'addNote')
+    addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
+    const toastsSpy = jest.spyOn(toastService, 'showError')
+    const addButton = fixture.debugElement.query(By.css('button'))
+    addButton.triggerEventHandler('click')
+    expect(addSpy).toHaveBeenCalledWith(12, note)
+    expect(toastsSpy).toHaveBeenCalled()
+
+    addSpy.mockReturnValueOnce(of([...notes, { id: 31, note, user: 1 }]))
+    addButton.triggerEventHandler('click')
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain(note)
+  })
+
+  it('should support note save on ctrl+Enter', () => {
+    component.documentId = 12
+    const note = 'This is the new note.'
+    const noteTextArea = fixture.debugElement.query(By.css('textarea'))
+    noteTextArea.nativeElement.value = note
+    const addSpy = jest.spyOn(component, 'addNote')
+    noteTextArea.nativeElement.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true })
+    )
+    expect(addSpy).toHaveBeenCalled()
+  })
+
+  it('should support delete note, show error if fails', () => {
+    component.documentId = 12
+    component.notes = notes
+    fixture.detectChanges()
+    const deleteButton = fixture.debugElement.queryAll(By.css('button'))[1] // 0 is add button
+    const deleteSpy = jest.spyOn(notesService, 'deleteNote')
+    const toastsSpy = jest.spyOn(toastService, 'showError')
+    deleteSpy.mockReturnValueOnce(
+      throwError(() => new Error('error deleting note'))
+    )
+    deleteButton.triggerEventHandler('click')
+    expect(deleteSpy).toHaveBeenCalledWith(12, notes[0].id)
+    expect(toastsSpy).toHaveBeenCalled()
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      notes[0].note
+    )
+
+    deleteSpy.mockReturnValueOnce(of(notes.slice(1, 2)))
+    deleteButton.triggerEventHandler('click')
+    expect(deleteSpy).toHaveBeenCalledWith(12, notes[0].id)
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).not.toContain(
+      notes[0].note
+    )
+  })
+})
index c005b917c9b1afca9dac714b3f4010f61137a360..b8c7d6fd965025c9e3013f8d45481ab31aa3a2d5 100644 (file)
@@ -2,7 +2,6 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'
 import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
 import { PaperlessDocumentNote } from 'src/app/data/paperless-document-note'
 import { FormControl, FormGroup } from '@angular/forms'
-import { first } from 'rxjs/operators'
 import { ToastService } from 'src/app/services/toast.service'
 import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
 import { UserService } from 'src/app/services/rest/user.service'
@@ -89,8 +88,8 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
     const user = this.users?.find((u) => u.id === note.user)
     if (!user) return ''
     const nameComponents = []
-    if (user.first_name) nameComponents.unshift(user.first_name)
-    if (user.last_name) nameComponents.unshift(user.last_name)
+    if (user.first_name) nameComponents.push(user.first_name)
+    if (user.last_name) nameComponents.push(user.last_name)
     if (user.username) {
       if (nameComponents.length > 0) nameComponents.push(`(${user.username})`)
       else nameComponents.push(user.username)
diff --git a/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.spec.ts b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.spec.ts
new file mode 100644 (file)
index 0000000..4f68a60
--- /dev/null
@@ -0,0 +1,70 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { CorrespondentListComponent } from './correspondent-list.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { DatePipe } from '@angular/common'
+import { SortableDirective } from 'src/app/directives/sortable.directive'
+import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
+import { of } from 'rxjs'
+
+describe('CorrespondentListComponent', () => {
+  let component: CorrespondentListComponent
+  let fixture: ComponentFixture<CorrespondentListComponent>
+  let correspondentsService: CorrespondentService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        CorrespondentListComponent,
+        SortableDirective,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+      ],
+      providers: [DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        NgbPaginationModule,
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+    }).compileComponents()
+
+    correspondentsService = TestBed.inject(CorrespondentService)
+    jest.spyOn(correspondentsService, 'listFiltered').mockReturnValue(
+      of({
+        count: 3,
+        all: [1, 2, 3],
+        results: [
+          {
+            id: 1,
+            name: 'Correspondent1',
+          },
+          {
+            id: 2,
+            name: 'Correspondent2',
+          },
+          {
+            id: 3,
+            name: 'Correspondent3',
+          },
+        ],
+      })
+    )
+    fixture = TestBed.createComponent(CorrespondentListComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  // Tests are included in management-list.compontent.spec.ts
+
+  it('should use correct delete message', () => {
+    expect(
+      component.getDeleteMessage({ id: 1, name: 'Correspondent1' })
+    ).toEqual(
+      'Do you really want to delete the correspondent "Correspondent1"?'
+    )
+  })
+})
diff --git a/src-ui/src/app/components/manage/document-type-list/document-type-list.component.spec.ts b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.spec.ts
new file mode 100644 (file)
index 0000000..1642d89
--- /dev/null
@@ -0,0 +1,68 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { DatePipe } from '@angular/common'
+import { SortableDirective } from 'src/app/directives/sortable.directive'
+import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { of } from 'rxjs'
+import { DocumentTypeListComponent } from './document-type-list.component'
+import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
+
+describe('DocumentTypeListComponent', () => {
+  let component: DocumentTypeListComponent
+  let fixture: ComponentFixture<DocumentTypeListComponent>
+  let documentTypeService: DocumentTypeService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        DocumentTypeListComponent,
+        SortableDirective,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+      ],
+      providers: [DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        NgbPaginationModule,
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+    }).compileComponents()
+
+    documentTypeService = TestBed.inject(DocumentTypeService)
+    jest.spyOn(documentTypeService, 'listFiltered').mockReturnValue(
+      of({
+        count: 3,
+        all: [1, 2, 3],
+        results: [
+          {
+            id: 1,
+            name: 'DocumentType1',
+          },
+          {
+            id: 2,
+            name: 'DocumentType2',
+          },
+          {
+            id: 3,
+            name: 'DocumentType3',
+          },
+        ],
+      })
+    )
+    fixture = TestBed.createComponent(DocumentTypeListComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  // Tests are included in management-list.compontent.spec.ts
+
+  it('should use correct delete message', () => {
+    expect(
+      component.getDeleteMessage({ id: 1, name: 'DocumentType1' })
+    ).toEqual('Do you really want to delete the document type "DocumentType1"?')
+  })
+})
diff --git a/src-ui/src/app/components/manage/logs/logs.component.spec.ts b/src-ui/src/app/components/manage/logs/logs.component.spec.ts
new file mode 100644 (file)
index 0000000..a9c4b46
--- /dev/null
@@ -0,0 +1,71 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { LogService } from 'src/app/services/rest/log.service'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { LogsComponent } from './logs.component'
+import { of, throwError } from 'rxjs'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
+import { BrowserModule, By } from '@angular/platform-browser'
+
+const paperless_logs = [
+  '[2023-05-29 03:05:01,224] [DEBUG] [paperless.tasks] Training data unchanged.',
+  '[2023-05-29 04:05:00,622] [DEBUG] [paperless.classifier] Gathering data from database...',
+  '[2023-05-29 04:05:01,213] [DEBUG] [paperless.tasks] Training data unchanged.',
+  '[2023-06-11 00:30:01,774] [INFO] [paperless.sanity_checker] Document contains no OCR data',
+  '[2023-06-11 00:30:01,774] [WARNING] [paperless.sanity_checker] Made up',
+  '[2023-06-11 00:30:01,774] [ERROR] [paperless.sanity_checker] Document contains no OCR data',
+  '[2023-06-11 00:30:01,774] [CRITICAL] [paperless.sanity_checker] Document contains no OCR data',
+]
+const mail_logs = [
+  '[2023-06-09 01:10:00,666] [DEBUG] [paperless_mail] Rule inbox@example.com.Incoming: Searching folder with criteria (SINCE 10-May-2023 UNSEEN)',
+  '[2023-06-09 01:10:01,385] [DEBUG] [paperless_mail] Rule inbox@example.com.Incoming: Processed 3 matching mail(s)',
+]
+
+describe('LogsComponent', () => {
+  let component: LogsComponent
+  let fixture: ComponentFixture<LogsComponent>
+  let logService: LogService
+  let logSpy
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [LogsComponent, PageHeaderComponent],
+      providers: [],
+      imports: [HttpClientTestingModule, BrowserModule, NgbModule],
+    }).compileComponents()
+
+    logService = TestBed.inject(LogService)
+    jest.spyOn(logService, 'list').mockReturnValue(of(['paperless', 'mail']))
+    logSpy = jest.spyOn(logService, 'get')
+    logSpy.mockImplementation((id) => {
+      return of(id === 'paperless' ? paperless_logs : mail_logs)
+    })
+    fixture = TestBed.createComponent(LogsComponent)
+    component = fixture.componentInstance
+    window.HTMLElement.prototype.scroll = function () {} // mock scroll
+    fixture.detectChanges()
+  })
+
+  it('should display logs with first log initially', () => {
+    expect(logSpy).toHaveBeenCalledWith('paperless')
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      paperless_logs[0]
+    )
+  })
+
+  it('should load log when tab clicked', () => {
+    fixture.debugElement
+      .queryAll(By.directive(NgbNavLink))[1]
+      .nativeElement.dispatchEvent(new MouseEvent('click'))
+    expect(logSpy).toHaveBeenCalledWith('mail')
+  })
+
+  it('should handle error with no logs', () => {
+    logSpy.mockReturnValueOnce(
+      throwError(() => new Error('error getting logs'))
+    )
+    component.reloadLogs()
+    expect(component.logs).toHaveLength(0)
+  })
+})
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts
new file mode 100644 (file)
index 0000000..9579e5b
--- /dev/null
@@ -0,0 +1,232 @@
+import { 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 {
+  NgbModal,
+  NgbModalModule,
+  NgbModalRef,
+  NgbPaginationModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { of, throwError } from 'rxjs'
+import { PaperlessTag } from 'src/app/data/paperless-tag'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SortableDirective } from 'src/app/directives/sortable.directive'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { TagService } from 'src/app/services/rest/tag.service'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { TagListComponent } from '../tag-list/tag-list.component'
+import { ManagementListComponent } from './management-list.component'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
+import { RouterTestingModule } from '@angular/router/testing'
+import { routes } from 'src/app/app-routing.module'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+import { MATCH_AUTO } from 'src/app/data/matching-model'
+import { MATCH_NONE } from 'src/app/data/matching-model'
+import { MATCH_LITERAL } from 'src/app/data/matching-model'
+
+const tags: PaperlessTag[] = [
+  {
+    id: 1,
+    name: 'Tag1 Foo',
+    matching_algorithm: MATCH_LITERAL,
+    match: 'foo',
+  },
+  {
+    id: 2,
+    name: 'Tag2',
+    matching_algorithm: MATCH_NONE,
+  },
+  {
+    id: 3,
+    name: 'Tag3',
+    matching_algorithm: MATCH_AUTO,
+  },
+]
+
+describe('ManagementListComponent', () => {
+  let component: ManagementListComponent<PaperlessTag>
+  let fixture: ComponentFixture<ManagementListComponent<PaperlessTag>>
+  let tagService: TagService
+  let modalService: NgbModal
+  let toastService: ToastService
+  let documentListViewService: DocumentListViewService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        TagListComponent,
+        SortableDirective,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+        SafeHtmlPipe,
+        ConfirmDialogComponent,
+      ],
+      providers: [
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+            currentUserHasObjectPermissions: () => true,
+            currentUserOwnsObject: () => true,
+          },
+        },
+        DatePipe,
+        PermissionsGuard,
+      ],
+      imports: [
+        HttpClientTestingModule,
+        NgbPaginationModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModalModule,
+        RouterTestingModule.withRoutes(routes),
+      ],
+    }).compileComponents()
+
+    tagService = TestBed.inject(TagService)
+    jest
+      .spyOn(tagService, 'listFiltered')
+      .mockImplementation(
+        (page, pageSize, sortField, sortReverse, nameFilter, fullPerms) => {
+          const results = nameFilter
+            ? tags.filter((t) => t.name.toLowerCase().includes(nameFilter))
+            : tags
+          return of({
+            count: results.length,
+            all: results.map((o) => o.id),
+            results,
+          })
+        }
+      )
+    modalService = TestBed.inject(NgbModal)
+    toastService = TestBed.inject(ToastService)
+    documentListViewService = TestBed.inject(DocumentListViewService)
+    fixture = TestBed.createComponent(TagListComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  // These tests are shared among all management list components
+
+  it('should support filtering, clear on Esc key', fakeAsync(() => {
+    const nameFilterInput = fixture.debugElement.query(By.css('input'))
+    nameFilterInput.nativeElement.value = 'foo'
+    // nameFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    component.nameFilter = 'foo' // subject normally triggered by ngModel
+    tick(400) // debounce
+    fixture.detectChanges()
+    expect(component.data).toEqual([tags[0]])
+
+    nameFilterInput.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { code: 'Escape' })
+    )
+    tick(400) // debounce
+    fixture.detectChanges()
+    expect(component.nameFilter).toBeNull()
+    expect(component.data).toEqual(tags)
+  }))
+
+  it('should support create, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const reloadSpy = jest.spyOn(component, 'reloadData')
+
+    const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
+    createButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog =
+      modal.componentInstance as EditDialogComponent<PaperlessTag>
+
+    // fail first
+    editDialog.failed.emit({ error: 'error creating item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit()
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support edit, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const reloadSpy = jest.spyOn(component, 'reloadData')
+
+    const editButton = fixture.debugElement.queryAll(By.css('button'))[3]
+    editButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog =
+      modal.componentInstance as EditDialogComponent<PaperlessTag>
+    expect(editDialog.object).toEqual(tags[0])
+
+    // fail first
+    editDialog.failed.emit({ error: 'error editing item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit()
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support delete, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const deleteSpy = jest.spyOn(tagService, 'delete')
+    const reloadSpy = jest.spyOn(component, 'reloadData')
+
+    const deleteButton = fixture.debugElement.queryAll(By.css('button'))[4]
+    deleteButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog = modal.componentInstance as ConfirmDialogComponent
+
+    // fail first
+    deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
+    editDialog.confirmClicked.emit()
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    deleteSpy.mockReturnValueOnce(of(true))
+    editDialog.confirmClicked.emit()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support quick filter for objects', () => {
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    const filterButton = fixture.debugElement.queryAll(By.css('button'))[2]
+    filterButton.triggerEventHandler('click')
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },
+    ]) // subclasses set the filter rule type
+  })
+
+  it('should reload on sort', () => {
+    const reloadSpy = jest.spyOn(component, 'reloadData')
+    const sortable = fixture.debugElement.query(By.directive(SortableDirective))
+    sortable.triggerEventHandler('click')
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+})
index 8264f707183aca59bf7d65126ef0557532c1d24c..b45c234ea17962ce2b0ba934dd69e4f64587b580 100644 (file)
@@ -28,7 +28,10 @@ import {
 import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service'
 import { ToastService } from 'src/app/services/toast.service'
 import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
-import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
+import {
+  EditDialogComponent,
+  EditDialogMode,
+} from '../../common/edit-dialog/edit-dialog.component'
 import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
 
 export interface ManagementListColumn {
@@ -135,7 +138,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
     var activeModal = this.modalService.open(this.editDialogComponent, {
       backdrop: 'static',
     })
-    activeModal.componentInstance.dialogMode = 'create'
+    activeModal.componentInstance.dialogMode = EditDialogMode.CREATE
     activeModal.componentInstance.succeeded.subscribe(() => {
       this.reloadData()
       this.toastService.showInfo(
@@ -156,7 +159,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
       backdrop: 'static',
     })
     activeModal.componentInstance.object = object
-    activeModal.componentInstance.dialogMode = 'edit'
+    activeModal.componentInstance.dialogMode = EditDialogMode.EDIT
     activeModal.componentInstance.succeeded.subscribe(() => {
       this.reloadData()
       this.toastService.showInfo(
@@ -172,9 +175,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
     })
   }
 
-  getDeleteMessage(object: T) {
-    return $localize`Do you really want to delete the ${this.typeName}?`
-  }
+  abstract getDeleteMessage(object: T)
 
   filterDocuments(object: ObjectWithId) {
     this.documentListViewService.quickFilter([
diff --git a/src-ui/src/app/components/manage/settings/settings.component.spec.ts b/src-ui/src/app/components/manage/settings/settings.component.spec.ts
new file mode 100644 (file)
index 0000000..f9f423f
--- /dev/null
@@ -0,0 +1,484 @@
+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,
+  NgbNavLink,
+  NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+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 { 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 { 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' },
+  { id: 2, username: 'user2' },
+]
+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' },
+  { id: 2, name: 'rule2' },
+]
+
+describe('SettingsComponent', () => {
+  let component: SettingsComponent
+  let fixture: ComponentFixture<SettingsComponent>
+  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 groupService: GroupService
+  let mailAccountService: MailAccountService
+  let mailRuleService: MailRuleService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        SettingsComponent,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+        CustomDatePipe,
+        ConfirmDialogComponent,
+        CheckComponent,
+        ColorComponent,
+      ],
+      providers: [
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+          },
+        },
+        CustomDatePipe,
+        DatePipe,
+        PermissionsGuard,
+      ],
+      imports: [
+        NgbModule,
+        HttpClientTestingModule,
+        RouterTestingModule.withRoutes(routes),
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+    }).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)
+    userService = TestBed.inject(UserService)
+    jest.spyOn(userService, 'listAll').mockReturnValue(
+      of({
+        all: users.map((u) => u.id),
+        count: users.length,
+        results: users.concat([]),
+      })
+    )
+    groupService = TestBed.inject(GroupService)
+    jest.spyOn(groupService, 'listAll').mockReturnValue(
+      of({
+        all: groups.map((g) => g.id),
+        count: groups.length,
+        results: groups.concat([]),
+      })
+    )
+    savedViewService = TestBed.inject(SavedViewService)
+    jest.spyOn(savedViewService, 'listAll').mockReturnValue(
+      of({
+        all: savedViews.map((v) => v.id),
+        count: savedViews.length,
+        results: (savedViews as PaperlessSavedView[]).concat([]),
+      })
+    )
+    mailAccountService = TestBed.inject(MailAccountService)
+    jest.spyOn(mailAccountService, 'listAll').mockReturnValue(
+      of({
+        all: mailAccounts.map((a) => a.id),
+        count: mailAccounts.length,
+        results: (mailAccounts as PaperlessMailAccount[]).concat([]),
+      })
+    )
+    mailRuleService = TestBed.inject(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', () => {
+    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', () => {
+    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', () => {
+    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.users).toBeUndefined()
+    tabButtons[4].nativeElement.dispatchEvent(
+      new MouseEvent('mouseover', { bubbles: true })
+    )
+    expect(component.users).not.toBeUndefined()
+  })
+
+  it('should support save saved views, show error', () => {
+    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', () => {
+    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(19)
+
+    // succeed
+    storeSpy.mockReturnValueOnce(of(true))
+    component.saveSettings()
+    expect(toastSpy).toHaveBeenCalled()
+    expect(appearanceSettingsSpy).toHaveBeenCalled()
+  })
+
+  it('should offer reload if settings changes require', () => {
+    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', () => {
+    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', () => {
+    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', () => {
+    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', () => {
+    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(() => {
+    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', () => {
+    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', () => {
+    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', () => {
+    component.maybeInitializeTab(5) // UsersGroups
+    expect(component.getGroupName(1)).toEqual(groups[0].name)
+    expect(component.getGroupName(11)).toEqual('')
+  })
+
+  it('should support edit / create mail account, show error if needed', () => {
+    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', () => {
+    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', () => {
+    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', () => {
+    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')
+  })
+})
index dc4f1746de96678f0f18638ce36b9eb92c8e65ce..c75867f7e01ab23eafb752d5f40b9d3b4e37d8db 100644 (file)
@@ -44,6 +44,7 @@ import { MailAccountService } from 'src/app/services/rest/mail-account.service'
 import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
 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 { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
 
 enum SettingsNavIDs {
   General = 1,
@@ -225,9 +226,9 @@ export class SettingsComponent
 
   onNavChange(navChangeEvent: NgbNavChangeEvent) {
     this.maybeInitializeTab(navChangeEvent.nextId)
-    const [foundNavIDkey, foundNavIDValue] = Object.entries(
-      SettingsNavIDs
-    ).find(([navIDkey, navIDValue]) => navIDValue == navChangeEvent.nextId)
+    const [foundNavIDkey] = Object.entries(SettingsNavIDs).find(
+      ([, navIDValue]) => navIDValue == navChangeEvent.nextId
+    )
     if (foundNavIDkey)
       // if its dirty we need to wait for confirmation
       this.router
@@ -579,8 +580,8 @@ export class SettingsComponent
             delay: 5000,
           }
           if (reloadRequired) {
-            ;(savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`),
-              (savedToast.actionName = $localize`Reload now`)
+            savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
+            savedToast.actionName = $localize`Reload now`
             savedToast.action = () => {
               location.reload()
             }
@@ -646,7 +647,9 @@ export class SettingsComponent
       backdrop: 'static',
       size: 'xl',
     })
-    modal.componentInstance.dialogMode = user ? 'edit' : 'create'
+    modal.componentInstance.dialogMode = user
+      ? EditDialogMode.EDIT
+      : EditDialogMode.CREATE
     modal.componentInstance.object = user
     modal.componentInstance.succeeded
       .pipe(takeUntil(this.unsubscribeNotifier))
@@ -718,7 +721,9 @@ export class SettingsComponent
       backdrop: 'static',
       size: 'lg',
     })
-    modal.componentInstance.dialogMode = group ? 'edit' : 'create'
+    modal.componentInstance.dialogMode = group
+      ? EditDialogMode.EDIT
+      : EditDialogMode.CREATE
     modal.componentInstance.object = group
     modal.componentInstance.succeeded
       .pipe(takeUntil(this.unsubscribeNotifier))
@@ -780,7 +785,9 @@ export class SettingsComponent
       backdrop: 'static',
       size: 'xl',
     })
-    modal.componentInstance.dialogMode = account ? 'edit' : 'create'
+    modal.componentInstance.dialogMode = account
+      ? EditDialogMode.EDIT
+      : EditDialogMode.CREATE
     modal.componentInstance.object = account
     modal.componentInstance.succeeded
       .pipe(takeUntil(this.unsubscribeNotifier))
@@ -842,7 +849,9 @@ export class SettingsComponent
       backdrop: 'static',
       size: 'xl',
     })
-    modal.componentInstance.dialogMode = rule ? 'edit' : 'create'
+    modal.componentInstance.dialogMode = rule
+      ? EditDialogMode.EDIT
+      : EditDialogMode.CREATE
     modal.componentInstance.object = rule
     modal.componentInstance.succeeded
       .pipe(takeUntil(this.unsubscribeNotifier))
diff --git a/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.spec.ts b/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.spec.ts
new file mode 100644 (file)
index 0000000..5571c44
--- /dev/null
@@ -0,0 +1,68 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
+import { of } from 'rxjs'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SortableDirective } from 'src/app/directives/sortable.directive'
+import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { StoragePathListComponent } from './storage-path-list.component'
+
+describe('StoragePathListComponent', () => {
+  let component: StoragePathListComponent
+  let fixture: ComponentFixture<StoragePathListComponent>
+  let storagePathService: StoragePathService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        StoragePathListComponent,
+        SortableDirective,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+      ],
+      providers: [DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        NgbPaginationModule,
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+    }).compileComponents()
+
+    storagePathService = TestBed.inject(StoragePathService)
+    jest.spyOn(storagePathService, 'listFiltered').mockReturnValue(
+      of({
+        count: 3,
+        all: [1, 2, 3],
+        results: [
+          {
+            id: 1,
+            name: 'StoragePath1',
+          },
+          {
+            id: 2,
+            name: 'StoragePath2',
+          },
+          {
+            id: 3,
+            name: 'StoragePath3',
+          },
+        ],
+      })
+    )
+    fixture = TestBed.createComponent(StoragePathListComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  // Tests are included in management-list.compontent.spec.ts
+
+  it('should use correct delete message', () => {
+    expect(component.getDeleteMessage({ id: 1, name: 'StoragePath1' })).toEqual(
+      'Do you really want to delete the storage path "StoragePath1"?'
+    )
+  })
+})
diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts
new file mode 100644 (file)
index 0000000..e7be903
--- /dev/null
@@ -0,0 +1,70 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
+import { of } from 'rxjs'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SortableDirective } from 'src/app/directives/sortable.directive'
+import { TagService } from 'src/app/services/rest/tag.service'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { TagListComponent } from './tag-list.component'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+
+describe('TagListComponent', () => {
+  let component: TagListComponent
+  let fixture: ComponentFixture<TagListComponent>
+  let tagService: TagService
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        TagListComponent,
+        SortableDirective,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+        SafeHtmlPipe,
+      ],
+      providers: [DatePipe],
+      imports: [
+        HttpClientTestingModule,
+        NgbPaginationModule,
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+    }).compileComponents()
+
+    tagService = TestBed.inject(TagService)
+    jest.spyOn(tagService, 'listFiltered').mockReturnValue(
+      of({
+        count: 3,
+        all: [1, 2, 3],
+        results: [
+          {
+            id: 1,
+            name: 'Tag1',
+          },
+          {
+            id: 2,
+            name: 'Tag2',
+          },
+          {
+            id: 3,
+            name: 'Tag3',
+          },
+        ],
+      })
+    )
+    fixture = TestBed.createComponent(TagListComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  // Tests are included in management-list.compontent.spec.ts
+
+  it('should use correct delete message', () => {
+    expect(component.getDeleteMessage({ id: 1, name: 'Tag1' })).toEqual(
+      'Do you really want to delete the tag "Tag1"?'
+    )
+  })
+})
index b27275712c5a5883bccbaf3661206f7227a24b8f..f1d728e721776e6e0293fe6778da555806210fed 100644 (file)
 
 <ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)">
   <li ngbNavItem="failed">
-    <a ngbNavLink i18n>Failed&nbsp;<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-1">{{tasksService.failedFileTasks.length}}</span></a>
+    <a ngbNavLink i18n>Failed<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></a>
     <ng-template ngbNavContent>
       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
     </ng-template>
   </li>
   <li ngbNavItem="completed">
-    <a ngbNavLink i18n>Complete&nbsp;<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.completedFileTasks.length}}</span></a>
+    <a ngbNavLink i18n>Complete<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span></a>
     <ng-template ngbNavContent>
       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
     </ng-template>
   </li>
   <li ngbNavItem="started">
-    <a ngbNavLink i18n>Started&nbsp;<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.startedFileTasks.length}}</span></a>
+    <a ngbNavLink i18n>Started<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span></a>
     <ng-template ngbNavContent>
       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
     </ng-template>
   </li>
   <li ngbNavItem="queued">
-    <a ngbNavLink i18n>Queued&nbsp;<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.queuedFileTasks.length}}</span></a>
+    <a ngbNavLink i18n>Queued<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span></a>
     <ng-template ngbNavContent>
       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
     </ng-template>
diff --git a/src-ui/src/app/components/manage/tasks/tasks.component.spec.ts b/src-ui/src/app/components/manage/tasks/tasks.component.spec.ts
new file mode 100644 (file)
index 0000000..c981dac
--- /dev/null
@@ -0,0 +1,272 @@
+import { DatePipe } from '@angular/common'
+import {
+  HttpTestingController,
+  HttpClientTestingModule,
+} from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { By } from '@angular/platform-browser'
+import { Router } from '@angular/router'
+import { RouterTestingModule } from '@angular/router/testing'
+import {
+  NgbModal,
+  NgbModule,
+  NgbNavItem,
+  NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { routes } from 'src/app/app-routing.module'
+import {
+  PaperlessTask,
+  PaperlessTaskType,
+  PaperlessTaskStatus,
+} from 'src/app/data/paperless-task'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { TasksService } from 'src/app/services/tasks.service'
+import { environment } from 'src/environments/environment'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { TasksComponent } from './tasks.component'
+import { PermissionsGuard } from 'src/app/guards/permissions.guard'
+
+const tasks: PaperlessTask[] = [
+  {
+    id: 467,
+    task_id: '11ca1a5b-9f81-442c-b2c8-7e4ae53657f1',
+    task_file_name: 'test.pdf',
+    date_created: new Date('2023-03-01T10:26:03.093116Z'),
+    date_done: new Date('2023-03-01T10:26:07.223048Z'),
+    type: PaperlessTaskType.File,
+    status: PaperlessTaskStatus.Failed,
+    result: 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
+    acknowledged: false,
+    related_document: null,
+  },
+  {
+    id: 466,
+    task_id: '10ca1a5b-3c08-442c-b2c8-7e4ae53657f1',
+    task_file_name: '191092.pdf',
+    date_created: new Date('2023-03-01T09:26:03.093116Z'),
+    date_done: new Date('2023-03-01T09:26:07.223048Z'),
+    type: PaperlessTaskType.File,
+    status: PaperlessTaskStatus.Failed,
+    result:
+      '191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
+    acknowledged: false,
+    related_document: null,
+  },
+  {
+    id: 465,
+    task_id: '3612d477-bb04-44e3-985b-ac580dd496d8',
+    task_file_name: 'Scan Jun 6, 2023 at 3.19 PM.pdf',
+    date_created: new Date('2023-06-06T15:22:05.722323-07:00'),
+    date_done: new Date('2023-06-06T15:22:14.564305-07:00'),
+    type: PaperlessTaskType.File,
+    status: PaperlessTaskStatus.Pending,
+    result: null,
+    acknowledged: false,
+    related_document: null,
+  },
+  {
+    id: 464,
+    task_id: '2eac4716-2aa6-4dcd-9953-264e11656d7e',
+    task_file_name: 'paperless-mail-l4dkg8ir',
+    date_created: new Date('2023-06-04T11:24:32.898089-07:00'),
+    date_done: new Date('2023-06-04T11:24:44.678605-07:00'),
+    type: PaperlessTaskType.File,
+    status: PaperlessTaskStatus.Complete,
+    result: 'Success. New document id 422 created',
+    acknowledged: false,
+    related_document: 422,
+  },
+  {
+    id: 463,
+    task_id: '28125528-1575-4d6b-99e6-168906e8fa5c',
+    task_file_name: 'onlinePaymentSummary.pdf',
+    date_created: new Date('2023-06-01T13:49:51.631305-07:00'),
+    date_done: new Date('2023-06-01T13:49:54.190220-07:00'),
+    type: PaperlessTaskType.File,
+    status: PaperlessTaskStatus.Complete,
+    result: 'Success. New document id 421 created',
+    acknowledged: false,
+    related_document: 421,
+  },
+  {
+    id: 462,
+    task_id: 'a5b9ca47-0c8e-490f-a04c-6db5d5fc09e5',
+    task_file_name: 'paperless-mail-_rrpmqk6',
+    date_created: new Date('2023-06-07T02:54:35.694916Z'),
+    date_done: null,
+    type: PaperlessTaskType.File,
+    status: PaperlessTaskStatus.Started,
+    result: null,
+    acknowledged: false,
+    related_document: null,
+  },
+]
+
+describe('TasksComponent', () => {
+  let component: TasksComponent
+  let fixture: ComponentFixture<TasksComponent>
+  let tasksService: TasksService
+  let modalService: NgbModal
+  let router: Router
+  let httpTestingController: HttpTestingController
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [
+        TasksComponent,
+        PageHeaderComponent,
+        IfPermissionsDirective,
+        CustomDatePipe,
+        ConfirmDialogComponent,
+      ],
+      providers: [
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+          },
+        },
+        CustomDatePipe,
+        DatePipe,
+        PermissionsGuard,
+      ],
+      imports: [
+        NgbModule,
+        HttpClientTestingModule,
+        RouterTestingModule.withRoutes(routes),
+      ],
+    }).compileComponents()
+
+    tasksService = TestBed.inject(TasksService)
+    httpTestingController = TestBed.inject(HttpTestingController)
+    modalService = TestBed.inject(NgbModal)
+    router = TestBed.inject(Router)
+    fixture = TestBed.createComponent(TasksComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}tasks/`)
+      .flush(tasks)
+  })
+
+  it('should display file tasks in 4 tabs by status', () => {
+    const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavItem))
+
+    let currentTasksLength = tasks.filter(
+      (t) => t.status === PaperlessTaskStatus.Failed
+    ).length
+    component.activeTab = 'failed'
+    fixture.detectChanges()
+    expect(tabButtons[0].nativeElement.textContent).toEqual(
+      `Failed${currentTasksLength}`
+    )
+    expect(
+      fixture.debugElement.queryAll(By.css('input[type="checkbox"]'))
+    ).toHaveLength(currentTasksLength + 1)
+
+    currentTasksLength = tasks.filter(
+      (t) => t.status === PaperlessTaskStatus.Complete
+    ).length
+    component.activeTab = 'completed'
+    fixture.detectChanges()
+    expect(tabButtons[1].nativeElement.textContent).toEqual(
+      `Complete${currentTasksLength}`
+    )
+
+    currentTasksLength = tasks.filter(
+      (t) => t.status === PaperlessTaskStatus.Started
+    ).length
+    component.activeTab = 'started'
+    fixture.detectChanges()
+    expect(tabButtons[2].nativeElement.textContent).toEqual(
+      `Started${currentTasksLength}`
+    )
+
+    currentTasksLength = tasks.filter(
+      (t) => t.status === PaperlessTaskStatus.Pending
+    ).length
+    component.activeTab = 'queued'
+    fixture.detectChanges()
+    expect(tabButtons[3].nativeElement.textContent).toEqual(
+      `Queued${currentTasksLength}`
+    )
+  })
+
+  it('should to go page 1 between tab switch', () => {
+    component.page = 10
+    component.duringTabChange(2)
+    expect(component.page).toEqual(1)
+  })
+
+  it('should support expanding / collapsing one task at a time', () => {
+    component.expandTask(tasks[0])
+    expect(component.expandedTask).toEqual(tasks[0].id)
+    component.expandTask(tasks[1])
+    expect(component.expandedTask).toEqual(tasks[1].id)
+    component.expandTask(tasks[1])
+    expect(component.expandedTask).toBeUndefined()
+  })
+
+  it('should support dismiss single task', () => {
+    const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
+    component.dismissTask(tasks[0])
+    expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id]))
+  })
+
+  it('should support dismiss specific checked tasks', () => {
+    component.toggleSelected(tasks[0])
+    component.toggleSelected(tasks[1])
+    component.toggleSelected(tasks[3])
+    component.toggleSelected(tasks[3]) // uncheck, for coverage
+    const selected = new Set([tasks[0].id, tasks[1].id])
+    expect(component.selectedTasks).toEqual(selected)
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
+    fixture.detectChanges()
+    component.dismissTasks()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirmClicked.emit()
+    expect(dismissSpy).toHaveBeenCalledWith(selected)
+  })
+
+  it('should support dismiss all tasks', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
+    component.dismissTasks()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.confirmClicked.emit()
+    expect(dismissSpy).toHaveBeenCalledWith(new Set(tasks.map((t) => t.id)))
+  })
+
+  it('should support toggle all tasks', () => {
+    const toggleCheck = fixture.debugElement.query(
+      By.css('input[type=checkbox]')
+    )
+    toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+    expect(component.selectedTasks).toEqual(
+      new Set(
+        tasks
+          .filter((t) => t.status === PaperlessTaskStatus.Failed)
+          .map((t) => t.id)
+      )
+    )
+    toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+    expect(component.selectedTasks).toEqual(new Set())
+  })
+
+  it('should support dismiss and open a document', () => {
+    const routerSpy = jest.spyOn(router, 'navigate')
+    component.dismissAndGo(tasks[3])
+    expect(routerSpy).toHaveBeenCalledWith([
+      'documents',
+      tasks[3].related_document,
+    ])
+  })
+})
index 5c89967aa1258dde0a7e1c6013861254b0d1eae6..9e993de621b4dabadae9855bf32f83aec05349ad 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, OnInit, OnDestroy } from '@angular/core'
 import { Router } from '@angular/router'
-import { NgbModal, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { Subject, first } from 'rxjs'
 import { PaperlessTask } from 'src/app/data/paperless-task'
 import { TasksService } from 'src/app/services/tasks.service'
@@ -51,8 +51,8 @@ export class TasksComponent
   }
 
   dismissTasks(task: PaperlessTask = undefined) {
-    let tasks = task ? new Set([task.id]) : this.selectedTasks
-    if (!task && this.selectedTasks.size == 0)
+    let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values())
+    if (!task && tasks.size == 0)
       tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id))
     if (tasks.size > 1) {
       let modal = this.modalService.open(ConfirmDialogComponent, {
@@ -91,7 +91,7 @@ export class TasksComponent
   }
 
   get currentTasks(): PaperlessTask[] {
-    let tasks: PaperlessTask[]
+    let tasks: PaperlessTask[] = []
     switch (this.activeTab) {
       case 'queued':
         tasks = this.tasksService.queuedFileTasks
@@ -105,8 +105,6 @@ export class TasksComponent
       case 'failed':
         tasks = this.tasksService.failedFileTasks
         break
-      default:
-        break
     }
     return tasks
   }
diff --git a/src-ui/src/app/components/not-found/not-found.component.spec.ts b/src-ui/src/app/components/not-found/not-found.component.spec.ts
new file mode 100644 (file)
index 0000000..8962d83
--- /dev/null
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NotFoundComponent } from './not-found.component'
+
+describe('NotFoundComponent', () => {
+  let component: NotFoundComponent
+  let fixture: ComponentFixture<NotFoundComponent>
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [NotFoundComponent],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(NotFoundComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should create component', () => {
+    expect(component).toBeTruthy()
+    expect(fixture.nativeElement.textContent).toContain('404 Not Found')
+  })
+})
diff --git a/src-ui/src/app/components/with-permissions/with-permissions.component.spec.ts b/src-ui/src/app/components/with-permissions/with-permissions.component.spec.ts
new file mode 100644 (file)
index 0000000..8d097d5
--- /dev/null
@@ -0,0 +1,18 @@
+import { TestBed } from '@angular/core/testing'
+import { ComponentWithPermissions } from './with-permissions.component'
+
+describe('ComponentWithPermissions', () => {
+  let component: ComponentWithPermissions
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [ComponentWithPermissions],
+    })
+  })
+
+  it('should include permissions classes', () => {
+    component = new ComponentWithPermissions()
+    expect(component.PermissionAction).not.toBeNull()
+    expect(component.PermissionType).not.toBeNull()
+  })
+})
index b0b9cb415e090c505243512f08267ae3c5933444..c52261c256ca3e217e35ccf4278c2a4c4baeaa16 100644 (file)
@@ -1,49 +1,3 @@
-import {
-  FILTER_FULLTEXT_MORELIKE,
-  FILTER_FULLTEXT_QUERY,
-} from './filter-rule-type'
-
-export function cloneFilterRules(filterRules: FilterRule[]): FilterRule[] {
-  if (filterRules) {
-    let newRules: FilterRule[] = []
-    for (let rule of filterRules) {
-      newRules.push({ rule_type: rule.rule_type, value: rule.value })
-    }
-    return newRules
-  } else {
-    return null
-  }
-}
-
-export function isFullTextFilterRule(filterRules: FilterRule[]): boolean {
-  return (
-    filterRules.find(
-      (r) =>
-        r.rule_type == FILTER_FULLTEXT_QUERY ||
-        r.rule_type == FILTER_FULLTEXT_MORELIKE
-    ) != null
-  )
-}
-
-export function filterRulesDiffer(
-  filterRulesA: FilterRule[],
-  filterRulesB: FilterRule[]
-): boolean {
-  let differ = false
-  if (filterRulesA.length != filterRulesB.length) {
-    differ = true
-  } else {
-    differ = filterRulesA.some((rule) => {
-      return (
-        filterRulesB.find(
-          (fri) => fri.rule_type == rule.rule_type && fri.value == rule.value
-        ) == undefined
-      )
-    })
-  }
-  return differ
-}
-
 export interface FilterRule {
   rule_type: number
   value: string
diff --git a/src-ui/src/app/directives/if-object-permissions.directive.spec.ts b/src-ui/src/app/directives/if-object-permissions.directive.spec.ts
new file mode 100644 (file)
index 0000000..ceda02d
--- /dev/null
@@ -0,0 +1,63 @@
+import { Component } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { IfObjectPermissionsDirective } from './if-object-permissions.directive'
+import { PermissionsService } from '../services/permissions.service'
+
+@Component({
+  template: `
+    <div>
+      <button
+        *appIfObjectPermissions="{
+          object: { id: 2, owner: user1 },
+          action: 'view'
+        }"
+      >
+        Some Text
+      </button>
+    </div>
+  `,
+})
+class TestComponent {}
+
+describe('IfObjectPermissionsDirective', () => {
+  let fixture: ComponentFixture<TestComponent>
+  let permissionsService: PermissionsService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [IfObjectPermissionsDirective, TestComponent],
+      providers: [PermissionsService],
+    })
+    permissionsService = TestBed.inject(PermissionsService)
+  })
+
+  it('should create element if user has object permissions', () => {
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockImplementation(() => {
+        return true
+      })
+
+    fixture = TestBed.createComponent(TestComponent)
+
+    fixture.detectChanges()
+
+    const rootEl = (fixture.nativeElement as HTMLDivElement).children[0]
+    expect(rootEl.querySelectorAll('button').length).toEqual(1)
+  })
+
+  it('should not create element if user does not have object permissions', () => {
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockImplementation(() => {
+        return false
+      })
+
+    fixture = TestBed.createComponent(TestComponent)
+
+    fixture.detectChanges()
+
+    const rootEl = (fixture.nativeElement as HTMLDivElement).children[0]
+    expect(rootEl.querySelectorAll('button').length).toEqual(0)
+  })
+})
diff --git a/src-ui/src/app/directives/if-owner.directive.spec.ts b/src-ui/src/app/directives/if-owner.directive.spec.ts
new file mode 100644 (file)
index 0000000..8e5d4fe
--- /dev/null
@@ -0,0 +1,56 @@
+import { Component } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { IfOwnerDirective } from './if-owner.directive'
+import { PermissionsService } from '../services/permissions.service'
+
+@Component({
+  template: `
+    <div>
+      <button *appIfOwner="{ id: 2, owner: user1 }">Some Text</button>
+    </div>
+  `,
+})
+class TestComponent {}
+
+describe('IfOwnerDirective', () => {
+  let fixture: ComponentFixture<TestComponent>
+  let permissionsService: PermissionsService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [IfOwnerDirective, TestComponent],
+      providers: [PermissionsService],
+    })
+    permissionsService = TestBed.inject(PermissionsService)
+  })
+
+  it('should create element if user owns object', () => {
+    jest
+      .spyOn(permissionsService, 'currentUserOwnsObject')
+      .mockImplementation(() => {
+        return true
+      })
+
+    fixture = TestBed.createComponent(TestComponent)
+
+    fixture.detectChanges()
+
+    const rootEl = (fixture.nativeElement as HTMLDivElement).children[0]
+    expect(rootEl.querySelectorAll('button').length).toEqual(1)
+  })
+
+  it('should not create element if user does not own object', () => {
+    jest
+      .spyOn(permissionsService, 'currentUserOwnsObject')
+      .mockImplementation(() => {
+        return false
+      })
+
+    fixture = TestBed.createComponent(TestComponent)
+
+    fixture.detectChanges()
+
+    const rootEl = (fixture.nativeElement as HTMLDivElement).children[0]
+    expect(rootEl.querySelectorAll('button').length).toEqual(0)
+  })
+})
diff --git a/src-ui/src/app/directives/if-permissions.directive.spec.ts b/src-ui/src/app/directives/if-permissions.directive.spec.ts
new file mode 100644 (file)
index 0000000..8dc926f
--- /dev/null
@@ -0,0 +1,54 @@
+import { Component } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { IfPermissionsDirective } from './if-permissions.directive'
+import { PermissionsService } from '../services/permissions.service'
+
+@Component({
+  template: `
+    <div>
+      <button *appIfPermissions="{ action: 'add', type: '%s_user' }">
+        Some Text
+      </button>
+    </div>
+  `,
+})
+class TestComponent {}
+
+describe('IfPermissionsDirective', () => {
+  let fixture: ComponentFixture<TestComponent>
+  let permissionsService: PermissionsService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [IfPermissionsDirective, TestComponent],
+      providers: [PermissionsService],
+    })
+    permissionsService = TestBed.inject(PermissionsService)
+  })
+
+  it('should create element if user has permissions', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockImplementation(() => {
+      return true
+    })
+
+    fixture = TestBed.createComponent(TestComponent)
+
+    fixture.detectChanges()
+
+    const rootEl = (fixture.nativeElement as HTMLDivElement).children[0]
+    expect(rootEl.querySelectorAll('button').length).toEqual(1)
+  })
+
+  it('should not create element if user has does not have permissions', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockImplementation(() => {
+      return false
+    })
+
+    fixture = TestBed.createComponent(TestComponent)
+
+    fixture.detectChanges()
+
+    const rootEl = (fixture.nativeElement as HTMLDivElement).children[0]
+    expect(rootEl.querySelectorAll('button').length).toEqual(0)
+  })
+})
diff --git a/src-ui/src/app/directives/sortable.directive.spec.ts b/src-ui/src/app/directives/sortable.directive.spec.ts
new file mode 100644 (file)
index 0000000..65f317a
--- /dev/null
@@ -0,0 +1,93 @@
+import { Component, DebugElement } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { SortEvent, SortableDirective } from './sortable.directive'
+import { By } from '@angular/platform-browser'
+
+@Component({
+  template: `
+    <table class="table">
+      <thead>
+        <th></th>
+        <th class="d-none d-lg-table-cell" appSortable="archive_serial_number">
+          ASN
+        </th>
+        <th class="d-none d-md-table-cell" appSortable="correspondent__name">
+          Correspondent
+        </th>
+      </thead>
+      <tbody>
+        <tr>
+          <td></td>
+          <td></td>
+          <td></td>
+        </tr>
+      </tbody>
+    </table>
+  `,
+})
+class TestComponent {}
+
+describe('SortableDirective', () => {
+  let fixture: ComponentFixture<TestComponent>
+  let directive: SortableDirective
+  let des: DebugElement[] // the elements w/ the directive
+
+  beforeEach(() => {
+    fixture = TestBed.configureTestingModule({
+      declarations: [SortableDirective, TestComponent],
+    }).createComponent(TestComponent)
+
+    fixture.detectChanges() // initial binding
+
+    // all elements with an attached SortableDirective
+    des = fixture.debugElement.queryAll(By.directive(SortableDirective))
+
+    directive = des[1].injector.get(SortableDirective)
+    directive.currentSortField = 'correspondent__name'
+  })
+
+  it('should have three 2 sortable elements', () => {
+    expect(des.length).toBe(2)
+  })
+
+  it('should trigger sort on click', () => {
+    const tableCell = des[1].nativeElement as HTMLTableCellElement
+
+    let sortEvent: SortEvent
+    directive.sort.subscribe((event) => {
+      directive.currentSortReverse = event.reverse
+      sortEvent = event
+    })
+
+    expect(directive.currentSortReverse).toBeFalsy()
+
+    tableCell.dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+
+    expect(sortEvent).not.toBeNull()
+    expect(sortEvent.column).toEqual('correspondent__name')
+    expect(sortEvent.reverse).toBeTruthy()
+
+    tableCell.dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+
+    expect(sortEvent.reverse).toBeFalsy()
+  })
+
+  it('should change column to sort when clicked', () => {
+    const tableCell = des[1].nativeElement as HTMLTableCellElement
+
+    let sortEvent: SortEvent
+    directive.sort.subscribe((event) => {
+      directive.currentSortReverse = event.reverse
+      sortEvent = event
+    })
+
+    directive.currentSortField = 'archive_serial_number'
+
+    tableCell.dispatchEvent(new MouseEvent('click'))
+    fixture.detectChanges()
+
+    expect(sortEvent.column).toEqual('correspondent__name')
+  })
+})
diff --git a/src-ui/src/app/guards/dirty-doc.guard.spec.ts b/src-ui/src/app/guards/dirty-doc.guard.spec.ts
new file mode 100644 (file)
index 0000000..1b99c1b
--- /dev/null
@@ -0,0 +1,48 @@
+import { TestBed } from '@angular/core/testing'
+import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { RouterTestingModule } from '@angular/router/testing'
+import { routes } from '../app-routing.module'
+import { Component } from '@angular/core'
+import { ComponentCanDeactivate, DirtyDocGuard } from './dirty-doc.guard'
+
+@Component({})
+class GenericDirtyDocComponent implements ComponentCanDeactivate {
+  canDeactivate: () => boolean
+}
+
+describe('DirtyDocGuard', () => {
+  let guard: DirtyDocGuard
+  let component: ComponentCanDeactivate
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [DirtyDocGuard, NgbModal, GenericDirtyDocComponent],
+      imports: [RouterTestingModule.withRoutes(routes), NgbModule],
+      declarations: [GenericDirtyDocComponent],
+    }).compileComponents()
+
+    guard = TestBed.inject(DirtyDocGuard)
+    const fixture = TestBed.createComponent(GenericDirtyDocComponent)
+    component = fixture.componentInstance
+    window.confirm = jest.fn().mockImplementation(() => true)
+
+    fixture.detectChanges()
+  })
+
+  it('should deactivate if component is not dirty', () => {
+    component.canDeactivate = () => true
+    const confirmSpy = jest.spyOn(window, 'confirm')
+    const canDeactivate = guard.canDeactivate(component)
+
+    expect(canDeactivate).toBeTruthy()
+    expect(confirmSpy).not.toHaveBeenCalled()
+  })
+
+  it('should not deactivate if component is dirty', () => {
+    component.canDeactivate = () => false
+    const confirmSpy = jest.spyOn(window, 'confirm')
+    const canDeactivate = guard.canDeactivate(component)
+
+    expect(confirmSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/guards/dirty-form.guard.spec.ts b/src-ui/src/app/guards/dirty-form.guard.spec.ts
new file mode 100644 (file)
index 0000000..24ee24f
--- /dev/null
@@ -0,0 +1,65 @@
+import { TestBed } from '@angular/core/testing'
+import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { RouterTestingModule } from '@angular/router/testing'
+import { routes } from '../app-routing.module'
+import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
+import { DirtyFormGuard } from './dirty-form.guard'
+import { DirtyComponent } from '@ngneat/dirty-check-forms'
+import { ActivatedRoute } from '@angular/router'
+import { Component } from '@angular/core'
+
+@Component({})
+class GenericDirtyComponent implements DirtyComponent {
+  isDirty$: boolean
+}
+
+describe('DirtyFormGuard', () => {
+  let guard: DirtyFormGuard
+  let component: DirtyComponent
+  let route: ActivatedRoute
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        DirtyFormGuard,
+        NgbModal,
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            snapshot: {},
+          },
+        },
+        GenericDirtyComponent,
+      ],
+      imports: [RouterTestingModule.withRoutes(routes), NgbModule],
+      declarations: [ConfirmDialogComponent, GenericDirtyComponent],
+    }).compileComponents()
+
+    guard = TestBed.inject(DirtyFormGuard)
+    route = TestBed.inject(ActivatedRoute)
+    const fixture = TestBed.createComponent(GenericDirtyComponent)
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should deactivate if component is not dirty', () => {
+    component.isDirty$ = false
+    const confirmSpy = jest.spyOn(guard, 'confirmChanges')
+    const canDeactivate = guard.canDeactivate(component, route.snapshot)
+    canDeactivate.subscribe()
+
+    expect(canDeactivate).toBeTruthy()
+    expect(confirmSpy).not.toHaveBeenCalled()
+  })
+
+  it('should offer confirm before deactivate if component is dirty', () => {
+    component.isDirty$ = true
+    const confirmSpy = jest.spyOn(guard, 'confirmChanges')
+    const canDeactivate = guard.canDeactivate(component, route.snapshot)
+    canDeactivate.subscribe()
+
+    expect(canDeactivate).toHaveProperty('source') // Observable
+    expect(confirmSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/guards/dirty-saved-view.guard.spec.ts b/src-ui/src/app/guards/dirty-saved-view.guard.spec.ts
new file mode 100644 (file)
index 0000000..f4388d9
--- /dev/null
@@ -0,0 +1,131 @@
+import { TestBed } from '@angular/core/testing'
+import { DirtySavedViewGuard } from './dirty-saved-view.guard'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
+import { SettingsService } from '../services/settings.service'
+import { DocumentListComponent } from '../components/document-list/document-list.component'
+import { RouterTestingModule } from '@angular/router/testing'
+import { routes } from '../app-routing.module'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
+
+describe('DirtySavedViewGuard', () => {
+  let guard: DirtySavedViewGuard
+  let settingsService: SettingsService
+  let modalService: NgbModal
+  let component: DocumentListComponent
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        DirtySavedViewGuard,
+        SettingsService,
+        NgbModal,
+        DocumentListComponent,
+      ],
+      imports: [
+        RouterTestingModule.withRoutes(routes),
+        HttpClientTestingModule,
+      ],
+      declarations: [ConfirmDialogComponent],
+    })
+
+    settingsService = TestBed.inject(SettingsService)
+    modalService = TestBed.inject(NgbModal)
+    guard = TestBed.inject(DirtySavedViewGuard)
+    const fixture = TestBed.createComponent(DocumentListComponent)
+    component = fixture.componentInstance
+  })
+
+  it('should deactivate if component is not dirty', () => {
+    jest
+      .spyOn(DocumentListComponent.prototype, 'savedViewIsModified', 'get')
+      .mockImplementation(() => {
+        return false
+      })
+    const canDeactivate = guard.canDeactivate(component)
+
+    expect(canDeactivate).toBeTruthy()
+  })
+
+  it('should not warn on deactivate if component is dirty & setting disabled', () => {
+    jest
+      .spyOn(DocumentListComponent.prototype, 'savedViewIsModified', 'get')
+      .mockImplementation(() => {
+        return true
+      })
+
+    jest.spyOn(settingsService, 'get').mockImplementation(() => {
+      return false
+    })
+
+    const modalSpy = jest.spyOn(modalService, 'open')
+
+    const canDeactivate = guard.canDeactivate(component)
+
+    expect(canDeactivate).toBeTruthy()
+    expect(modalSpy).not.toHaveBeenCalled()
+
+    const saveSpy = jest.spyOn(component, 'saveViewConfig')
+    expect(saveSpy).not.toHaveBeenCalled()
+  })
+
+  it('should warn on deactivate if component is dirty & setting enabled', () => {
+    jest
+      .spyOn(DocumentListComponent.prototype, 'savedViewIsModified', 'get')
+      .mockImplementation(() => {
+        return true
+      })
+
+    jest.spyOn(settingsService, 'get').mockImplementation(() => {
+      return true
+    })
+
+    const modalSpy = jest.spyOn(modalService, 'open')
+
+    let modal: NgbModalRef
+
+    modalService.activeInstances.subscribe((ngbmodalRef) => {
+      modal = ngbmodalRef[0]
+    })
+
+    const canDeactivate = guard.canDeactivate(component)
+
+    expect(canDeactivate).toHaveProperty('closed') // returns confirm dialog subject
+    expect(modalSpy).toHaveBeenCalled()
+    expect(modal).not.toBeNull()
+
+    const saveSpy = jest.spyOn(component, 'saveViewConfig')
+    modal.componentInstance.alternativeClicked.emit()
+    expect(saveSpy).toHaveBeenCalled()
+  })
+
+  it('should not save if user proceeds on warn', () => {
+    jest
+      .spyOn(DocumentListComponent.prototype, 'savedViewIsModified', 'get')
+      .mockImplementation(() => {
+        return true
+      })
+
+    jest.spyOn(settingsService, 'get').mockImplementation(() => {
+      return true
+    })
+
+    const modalSpy = jest.spyOn(modalService, 'open')
+
+    let modal: NgbModalRef
+
+    modalService.activeInstances.subscribe((ngbmodalRef) => {
+      modal = ngbmodalRef[0]
+    })
+
+    const canDeactivate = guard.canDeactivate(component)
+
+    expect(canDeactivate).toHaveProperty('closed') // returns confirm dialog subject
+    expect(modalSpy).toHaveBeenCalled()
+    expect(modal).not.toBeNull()
+
+    const saveSpy = jest.spyOn(component, 'saveViewConfig')
+    modal.componentInstance.confirmClicked.emit()
+    expect(saveSpy).not.toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/guards/permissions.guard.spec.ts b/src-ui/src/app/guards/permissions.guard.spec.ts
new file mode 100644 (file)
index 0000000..76b7c39
--- /dev/null
@@ -0,0 +1,100 @@
+import { TestBed } from '@angular/core/testing'
+import { PermissionsGuard } from './permissions.guard'
+import {
+  PermissionAction,
+  PermissionType,
+  PermissionsService,
+} from '../services/permissions.service'
+import { ActivatedRoute } from '@angular/router'
+import { RouterStateSnapshot } from '@angular/router'
+import { TourService } from 'ngx-ui-tour-ng-bootstrap'
+import { ToastService } from '../services/toast.service'
+import { RouterState } from '@angular/router'
+
+describe('PermissionsGuard', () => {
+  let guard: PermissionsGuard
+  let permissionsService: PermissionsService
+  let route: ActivatedRoute
+  let routerState: RouterState
+  let tourService: TourService
+  let toastService: ToastService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        PermissionsGuard,
+        PermissionsService,
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            snapshot: {
+              data: {
+                requiredPermission: {
+                  action: PermissionAction.View,
+                  type: PermissionType.Document,
+                },
+              },
+            },
+          },
+        },
+        {
+          provide: RouterState,
+          useValue: {
+            snapshot: {
+              url: '/documents',
+            },
+          },
+        },
+        TourService,
+        ToastService,
+      ],
+    })
+
+    permissionsService = TestBed.inject(PermissionsService)
+    tourService = TestBed.inject(TourService)
+    toastService = TestBed.inject(ToastService)
+    guard = TestBed.inject(PermissionsGuard)
+    route = TestBed.inject(ActivatedRoute)
+    routerState = TestBed.inject(RouterState)
+  })
+
+  it('should activate if user has permissions', () => {
+    jest
+      .spyOn(permissionsService, 'currentUserCan')
+      .mockImplementation((action, type) => {
+        return true
+      })
+
+    const canActivate = guard.canActivate(route.snapshot, routerState.snapshot)
+
+    expect(canActivate).toBeTruthy()
+  })
+
+  it('should not activate if user does not have permissions', () => {
+    jest
+      .spyOn(permissionsService, 'currentUserCan')
+      .mockImplementation((action, type) => {
+        return false
+      })
+
+    const canActivate = guard.canActivate(route.snapshot, routerState.snapshot)
+
+    expect(canActivate).toHaveProperty('root') // returns UrlTree
+  })
+
+  it('should not activate if user does not have permissions and tour is running', () => {
+    jest
+      .spyOn(permissionsService, 'currentUserCan')
+      .mockImplementation((action, type) => {
+        return false
+      })
+    jest.spyOn(tourService, 'getStatus').mockImplementation(() => 2)
+
+    const toastSpy = jest.spyOn(toastService, 'showError')
+
+    const canActivate = guard.canActivate(route.snapshot, routerState.snapshot)
+
+    expect(canActivate).toHaveProperty('root') // returns UrlTree
+    expect(toastSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/interceptors/api-version.interceptor.spec.ts b/src-ui/src/app/interceptors/api-version.interceptor.spec.ts
new file mode 100644 (file)
index 0000000..e3a2d7e
--- /dev/null
@@ -0,0 +1,30 @@
+import { TestBed } from '@angular/core/testing'
+import { ApiVersionInterceptor } from './api-version.interceptor'
+import { HttpEvent, HttpRequest } from '@angular/common/http'
+import { of } from 'rxjs'
+import { environment } from 'src/environments/environment'
+
+describe('ApiVersionInterceptor', () => {
+  let interceptor: ApiVersionInterceptor
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [ApiVersionInterceptor],
+    })
+
+    interceptor = TestBed.inject(ApiVersionInterceptor)
+  })
+
+  it('should add api version to headers', () => {
+    interceptor.intercept(new HttpRequest('GET', 'https://example.com'), {
+      handle: (request) => {
+        const header = request.headers['lazyUpdate'][0]
+        expect(header.name).toEqual('Accept')
+        expect(header.value).toEqual(
+          `application/json; version=${environment.apiVersion}`
+        )
+        return of({} as HttpEvent<any>)
+      },
+    })
+  })
+})
index df387acc14a80d29a561ecff3346058fbb163b0c..78ebeb3185fd6d6927a6ef6dfe0261093982c341 100644 (file)
@@ -1,16 +1,35 @@
 import { TestBed } from '@angular/core/testing'
-
 import { CsrfInterceptor } from './csrf.interceptor'
+import { Meta } from '@angular/platform-browser'
+import { HttpEvent, HttpRequest } from '@angular/common/http'
+import { of } from 'rxjs'
+import { CookieService } from 'ngx-cookie-service'
 
 describe('CsrfInterceptor', () => {
-  beforeEach(() =>
+  let interceptor: CsrfInterceptor
+  let meta: Meta
+  let cookieService: CookieService
+
+  beforeEach(() => {
     TestBed.configureTestingModule({
-      providers: [CsrfInterceptor],
+      providers: [CsrfInterceptor, Meta, CookieService],
     })
-  )
 
-  it('should be created', () => {
-    const interceptor: CsrfInterceptor = TestBed.inject(CsrfInterceptor)
-    expect(interceptor).toBeTruthy()
+    meta = TestBed.inject(Meta)
+    cookieService = TestBed.inject(CookieService)
+    interceptor = TestBed.inject(CsrfInterceptor)
+  })
+
+  it('should get csrf token', () => {
+    meta.addTag({ name: 'cookie_prefix', content: 'ngx-' }, true)
+    const cookieServiceSpy = jest.spyOn(cookieService, 'get')
+    cookieServiceSpy.mockReturnValue('csrftoken')
+    interceptor.intercept(new HttpRequest('GET', 'https://example.com'), {
+      handle: (request) => {
+        expect(request.headers['lazyUpdate'][0]['name']).toEqual('X-CSRFToken')
+        return of({} as HttpEvent<any>)
+      },
+    })
+    expect(cookieServiceSpy).toHaveBeenCalled()
   })
 })
index eb136e489e0fe0da590b5c9d1ed13b3d2c3d9885..7408938b9873712e66a797e202259d362a90f906 100644 (file)
@@ -21,7 +21,7 @@ export class CsrfInterceptor implements HttpInterceptor {
     if (this.meta.getTag('name=cookie_prefix')) {
       prefix = this.meta.getTag('name=cookie_prefix').content
     }
-    let csrfToken = this.cookieService.get(`${prefix ? prefix : ''}csrftoken`)
+    let csrfToken = this.cookieService.get(`${prefix}csrftoken`)
     if (csrfToken) {
       request = request.clone({
         setHeaders: {
diff --git a/src-ui/src/app/pipes/custom-date.pipe.spec.ts b/src-ui/src/app/pipes/custom-date.pipe.spec.ts
new file mode 100644 (file)
index 0000000..5b9d0b1
--- /dev/null
@@ -0,0 +1,33 @@
+import { TestBed } from '@angular/core/testing'
+import { CustomDatePipe } from './custom-date.pipe'
+import { SettingsService } from '../services/settings.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { DatePipe } from '@angular/common'
+
+describe('CustomDatePipe', () => {
+  let datePipe: CustomDatePipe
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [CustomDatePipe, SettingsService, DatePipe],
+      imports: [HttpClientTestingModule],
+    })
+
+    datePipe = TestBed.inject(CustomDatePipe)
+  })
+
+  it('should parse date strings with additional options', () => {
+    expect(datePipe.transform('5/4/23')).toEqual('May 4, 2023')
+    expect(
+      datePipe.transform(
+        '5/4/23',
+        'mediumDate',
+        'America/Los_Angeles',
+        'iso-8601'
+      )
+    ).toEqual('2023-05-04')
+  })
+})
index 08afd244e12f094fec337ef49ca1a23b8d8bc188..0ef68bf3e059a9b467a3914bd77a96a120fa78c8 100644 (file)
@@ -1,8 +1,9 @@
 import { DocumentTitlePipe } from './document-title.pipe'
 
 describe('DocumentTitlePipe', () => {
-  it('create an instance', () => {
+  it('should return a value if not null', () => {
     const pipe = new DocumentTitlePipe()
-    expect(pipe).toBeTruthy()
+    expect(pipe.transform('some string')).toEqual('some string')
+    expect(pipe.transform(null)).toEqual('(no title)')
   })
 })
index b84500fade4cf7e9c85012d6a0d5b0c45eb13ab5..42737ae9b80432d769f066245b030813c0bb756a 100644 (file)
@@ -1,8 +1,20 @@
 import { FileSizePipe } from './file-size.pipe'
 
 describe('FileSizePipe', () => {
-  it('create an instance', () => {
+  it('should return file size', () => {
     const pipe = new FileSizePipe()
-    expect(pipe).toBeTruthy()
+    expect(pipe.transform(1024, 1)).toEqual('1.0 KB')
+    expect(pipe.transform(1024 * 1024, 1)).toEqual('1.0 MB')
+    expect(
+      pipe.transform(1024, {
+        bytes: 0,
+        KB: 3,
+        MB: 1,
+        GB: 1,
+        TB: 2,
+        PB: 2,
+      })
+    ).toEqual('1.000 KB')
+    expect(pipe.transform(NaN, 1)).toEqual('?')
   })
 })
diff --git a/src-ui/src/app/pipes/filter.pipe.spec.ts b/src-ui/src/app/pipes/filter.pipe.spec.ts
new file mode 100644 (file)
index 0000000..9c4bfb3
--- /dev/null
@@ -0,0 +1,28 @@
+import { MatchingModel } from '../data/matching-model'
+import { FilterPipe } from './filter.pipe'
+
+describe('FilterPipe', () => {
+  it('should filter matchingmodel items', () => {
+    const pipe = new FilterPipe()
+    const items: MatchingModel[] = [
+      {
+        id: 1,
+        name: 'Hello World',
+        slug: 'slug-1',
+      },
+      {
+        id: 2,
+        name: 'Hello',
+        slug: 'slug-2',
+      },
+    ]
+    let itemsReturned = pipe.transform(items, 'world')
+    expect(itemsReturned).toEqual([items[0]])
+
+    itemsReturned = pipe.transform(null, 'world')
+    expect(itemsReturned).toEqual([])
+
+    itemsReturned = pipe.transform(items, null)
+    expect(itemsReturned).toEqual(items)
+  })
+})
diff --git a/src-ui/src/app/pipes/safehtml.pipe.spec.ts b/src-ui/src/app/pipes/safehtml.pipe.spec.ts
new file mode 100644 (file)
index 0000000..28fb9de
--- /dev/null
@@ -0,0 +1,24 @@
+import { TestBed } from '@angular/core/testing'
+import { SafeHtmlPipe } from './safehtml.pipe'
+import { BrowserModule, DomSanitizer } from '@angular/platform-browser'
+
+describe('SafeHtmlPipe', () => {
+  let pipe: SafeHtmlPipe
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [SafeHtmlPipe],
+      imports: [BrowserModule],
+    })
+    pipe = TestBed.inject(SafeHtmlPipe)
+  })
+
+  it('should bypass security and trust the url', () => {
+    const html = '<div>some content</div>'
+    const domSanitizer = TestBed.inject(DomSanitizer)
+    const sanitizerSpy = jest.spyOn(domSanitizer, 'bypassSecurityTrustHtml')
+    let safeHtml = pipe.transform(html)
+    expect(safeHtml).not.toBeNull()
+    expect(sanitizerSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/pipes/safeurl.pipe.spec.ts b/src-ui/src/app/pipes/safeurl.pipe.spec.ts
new file mode 100644 (file)
index 0000000..cf34a0d
--- /dev/null
@@ -0,0 +1,32 @@
+import { TestBed } from '@angular/core/testing'
+import { SafeUrlPipe } from './safeurl.pipe'
+import { BrowserModule, DomSanitizer } from '@angular/platform-browser'
+
+describe('SafeUrlPipe', () => {
+  let pipe: SafeUrlPipe
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [SafeUrlPipe],
+      imports: [BrowserModule],
+    })
+    pipe = TestBed.inject(SafeUrlPipe)
+  })
+
+  it('should bypass security and trust the url', () => {
+    const url = 'https://example.com'
+    const domSanitizer = TestBed.inject(DomSanitizer)
+    const sanitizerSpy = jest.spyOn(
+      domSanitizer,
+      'bypassSecurityTrustResourceUrl'
+    )
+
+    let safeResourceUrl = pipe.transform(url)
+    expect(safeResourceUrl).not.toBeNull()
+    expect(sanitizerSpy).toHaveBeenCalled()
+
+    safeResourceUrl = pipe.transform(null)
+    expect(safeResourceUrl).not.toBeNull()
+    expect(sanitizerSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/pipes/username.pipe.spec.ts b/src-ui/src/app/pipes/username.pipe.spec.ts
new file mode 100644 (file)
index 0000000..cab2116
--- /dev/null
@@ -0,0 +1,73 @@
+import { TestBed } from '@angular/core/testing'
+import { UsernamePipe } from './username.pipe'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { environment } from 'src/environments/environment'
+import { PermissionsService } from '../services/permissions.service'
+import { UserService } from '../services/rest/user.service'
+
+describe('UsernamePipe', () => {
+  let pipe: UsernamePipe
+  let httpTestingController: HttpTestingController
+  let permissionsService: PermissionsService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [UsernamePipe, PermissionsService],
+      imports: [HttpClientTestingModule],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    permissionsService = TestBed.inject(PermissionsService)
+    const permissionsSpy = jest.spyOn(permissionsService, 'currentUserCan')
+    permissionsSpy.mockImplementation((action, type) => {
+      return true
+    })
+    pipe = TestBed.inject(UsernamePipe)
+  })
+
+  afterEach(() => {
+    httpTestingController.verify()
+  })
+
+  it('should transform user id to username', () => {
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}users/?page=1&page_size=100000`
+    )
+    req.flush({
+      results: [
+        {
+          id: 2,
+          username: 'username2',
+        },
+        {
+          id: 3,
+          username: 'username3',
+          first_name: 'User',
+          last_name: 'Name3',
+        },
+      ],
+    })
+
+    let username = pipe.transform(2)
+    expect(username).toEqual('username2')
+
+    username = pipe.transform(3)
+    expect(username).toEqual('User Name3')
+
+    username = pipe.transform(4)
+    expect(username).toEqual('')
+  })
+
+  it('should show generic label when no users retrieved', () => {
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}users/?page=1&page_size=100000`
+    )
+    req.flush(null)
+
+    let username = pipe.transform(4)
+    expect(username).toEqual('Shared')
+  })
+})
index 3da9518e99f88c93e9a7c0e2e562aa1167696b2a..1309790a41b8bdf0507a384db8d9297cd81d370c 100644 (file)
@@ -1,8 +1,9 @@
 import { YesNoPipe } from './yes-no.pipe'
 
 describe('YesNoPipe', () => {
-  it('create an instance', () => {
+  it('should convert booleans to yes / no', () => {
     const pipe = new YesNoPipe()
-    expect(pipe).toBeTruthy()
+    expect(pipe.transform(true)).toEqual('Yes')
+    expect(pipe.transform(false)).toEqual('No')
   })
 })
diff --git a/src-ui/src/app/services/consumer-status.service.spec.ts b/src-ui/src/app/services/consumer-status.service.spec.ts
new file mode 100644 (file)
index 0000000..5c93f51
--- /dev/null
@@ -0,0 +1,254 @@
+import { TestBed } from '@angular/core/testing'
+import {
+  ConsumerStatusService,
+  FILE_STATUS_MESSAGES,
+  FileStatusPhase,
+} from './consumer-status.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { environment } from 'src/environments/environment'
+import { DocumentService } from './rest/document.service'
+import { HttpEventType, HttpResponse } from '@angular/common/http'
+import WS from 'jest-websocket-mock'
+
+describe('ConsumerStatusService', () => {
+  let httpTestingController: HttpTestingController
+  let consumerStatusService: ConsumerStatusService
+  let documentService: DocumentService
+  const server = new WS(
+    `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`,
+    { jsonProtocol: true }
+  )
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [ConsumerStatusService, DocumentService],
+      imports: [HttpClientTestingModule],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    consumerStatusService = TestBed.inject(ConsumerStatusService)
+    documentService = TestBed.inject(DocumentService)
+  })
+
+  afterEach(() => {
+    httpTestingController.verify()
+  })
+
+  it('should update status on websocket processing progress', () => {
+    const task_id = '1234'
+    const status = consumerStatusService.newFileUpload('file.pdf')
+    expect(status.getProgress()).toEqual(0)
+
+    consumerStatusService.connect()
+
+    consumerStatusService
+      .onDocumentConsumptionFinished()
+      .subscribe((filestatus) => {
+        expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS)
+      })
+
+    consumerStatusService.onDocumentDetected().subscribe((filestatus) => {
+      expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
+    })
+
+    server.send({
+      task_id,
+      filename: 'file.pdf',
+      current_progress: 50,
+      max_progress: 100,
+      document_id: 12,
+      status: 'STARTING',
+    })
+
+    expect(status.getProgress()).toBeCloseTo(0.6) // 0.8 * 50/100
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
+      status,
+    ])
+
+    server.send({
+      task_id,
+      filename: 'file.pdf',
+      current_progress: 100,
+      max_progress: 100,
+      document_id: 12,
+      status: 'SUCCESS',
+      message: FILE_STATUS_MESSAGES.finished,
+    })
+
+    expect(status.getProgress()).toEqual(1)
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
+      0
+    )
+    expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
+
+    consumerStatusService.disconnect()
+  })
+
+  it('should update status on websocket failed progress', () => {
+    const task_id = '1234'
+    const status = consumerStatusService.newFileUpload('file.pdf')
+    status.taskId = task_id
+    consumerStatusService.connect()
+
+    consumerStatusService
+      .onDocumentConsumptionFailed()
+      .subscribe((filestatus) => {
+        expect(filestatus.phase).toEqual(FileStatusPhase.FAILED)
+      })
+
+    server.send({
+      task_id,
+      filename: 'file.pdf',
+      current_progress: 50,
+      max_progress: 100,
+      document_id: 12,
+    })
+
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
+      status,
+    ])
+
+    server.send({
+      task_id,
+      filename: 'file.pdf',
+      current_progress: 50,
+      max_progress: 100,
+      document_id: 12,
+      status: 'FAILED',
+      message: FILE_STATUS_MESSAGES.document_already_exists,
+    })
+
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
+      0
+    )
+    expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
+  })
+
+  it('should update status on upload progress', () => {
+    const task_id = '1234'
+    const status = consumerStatusService.newFileUpload('file.pdf')
+
+    documentService.uploadDocument({}).subscribe((event) => {
+      if (event.type === HttpEventType.Response) {
+        status.taskId = event.body['task_id']
+        status.message = $localize`Upload complete, waiting...`
+      } else if (event.type === HttpEventType.UploadProgress) {
+        status.updateProgress(
+          FileStatusPhase.UPLOADING,
+          event.loaded,
+          event.total
+        )
+      }
+    })
+
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/post_document/`
+    )
+
+    req.event(
+      new HttpResponse({
+        body: {
+          task_id,
+        },
+      })
+    )
+
+    req.event({
+      type: HttpEventType.UploadProgress,
+      loaded: 100,
+      total: 300,
+    })
+
+    expect(
+      consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
+    ).toEqual([status])
+    expect(consumerStatusService.getConsumerStatus()).toEqual([status])
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
+      status,
+    ])
+
+    req.event({
+      type: HttpEventType.UploadProgress,
+      loaded: 300,
+      total: 300,
+    })
+
+    expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300
+  })
+
+  it('should support dismiss completed', () => {
+    consumerStatusService.connect()
+    server.send({
+      task_id: '1234',
+      filename: 'file.pdf',
+      current_progress: 100,
+      max_progress: 100,
+      document_id: 12,
+      status: 'SUCCESS',
+      message: 'finished',
+    })
+
+    expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
+    consumerStatusService.dismissCompleted()
+    expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0)
+  })
+
+  it('should support dismiss', () => {
+    const task_id = '1234'
+    const status = consumerStatusService.newFileUpload('file.pdf')
+    status.taskId = task_id
+    status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
+
+    const status2 = consumerStatusService.newFileUpload('file2.pdf')
+    status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
+
+    expect(
+      consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
+    ).toEqual([status, status2])
+    expect(consumerStatusService.getConsumerStatus()).toEqual([status, status2])
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
+      status,
+      status2,
+    ])
+
+    consumerStatusService.dismiss(status)
+    expect(consumerStatusService.getConsumerStatus()).toEqual([status2])
+
+    consumerStatusService.dismiss(status2)
+    expect(consumerStatusService.getConsumerStatus()).toHaveLength(0)
+  })
+
+  it('should support fail', () => {
+    const task_id = '1234'
+    const status = consumerStatusService.newFileUpload('file.pdf')
+    status.taskId = task_id
+    status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
+      1
+    )
+    expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0)
+    consumerStatusService.fail(status, 'fail')
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
+      0
+    )
+    expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
+  })
+
+  it('should notify of document created on status message without upload', () => {
+    consumerStatusService.onDocumentDetected().subscribe((filestatus) => {
+      expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
+    })
+
+    server.send({
+      task_id: '1234',
+      filename: 'file.pdf',
+      current_progress: 50,
+      max_progress: 100,
+      document_id: 12,
+      status: 'STARTING',
+    })
+  })
+})
diff --git a/src-ui/src/app/services/document-list-view.service.spec.ts b/src-ui/src/app/services/document-list-view.service.spec.ts
new file mode 100644 (file)
index 0000000..2a63e21
--- /dev/null
@@ -0,0 +1,428 @@
+import { TestBed } from '@angular/core/testing'
+import { DocumentListViewService } from './document-list-view.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { environment } from 'src/environments/environment'
+import { Subscription } from 'rxjs'
+import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
+import { Params, Router, convertToParamMap } from '@angular/router'
+import {
+  FILTER_HAS_TAGS_ALL,
+  FILTER_HAS_TAGS_ANY,
+} from '../data/filter-rule-type'
+import { PaperlessSavedView } from '../data/paperless-saved-view'
+import { FilterRule } from '../data/filter-rule'
+import { RouterTestingModule } from '@angular/router/testing'
+import { routes } from 'src/app/app-routing.module'
+import { PermissionsGuard } from '../guards/permissions.guard'
+import { SettingsService } from './settings.service'
+import { SETTINGS_KEYS } from '../data/paperless-uisettings'
+
+const documents = [
+  {
+    id: 1,
+    title: 'Doc 1',
+    content: 'some content',
+    tags: [1, 2, 3],
+    correspondent: 11,
+    document_type: 3,
+    storage_path: 8,
+  },
+  {
+    id: 2,
+    title: 'Doc 2',
+    content: 'some content',
+  },
+  {
+    id: 3,
+    title: 'Doc 3',
+    content: 'some content',
+  },
+  {
+    id: 4,
+    title: 'Doc 4',
+    content: 'some content',
+  },
+  {
+    id: 5,
+    title: 'Doc 5',
+    content: 'some content',
+  },
+  {
+    id: 6,
+    title: 'Doc 6',
+    content: 'some content',
+  },
+]
+const full_results = {
+  count: documents.length,
+  results: documents,
+}
+
+const tags__id__all = '9'
+const filterRules: FilterRule[] = [
+  {
+    rule_type: FILTER_HAS_TAGS_ALL,
+    value: tags__id__all,
+  },
+]
+
+const view: PaperlessSavedView = {
+  id: 3,
+  name: 'Saved View',
+  sort_field: 'added',
+  sort_reverse: true,
+  filter_rules: filterRules,
+}
+
+describe('DocumentListViewService', () => {
+  let httpTestingController: HttpTestingController
+  let documentListViewService: DocumentListViewService
+  let subscriptions: Subscription[] = []
+  let router: Router
+  let settingsService: SettingsService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [DocumentListViewService, PermissionsGuard, SettingsService],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule.withRoutes(routes),
+      ],
+      declarations: [ConfirmDialogComponent],
+      teardown: { destroyAfterEach: true },
+    })
+
+    sessionStorage.clear()
+    httpTestingController = TestBed.inject(HttpTestingController)
+    documentListViewService = TestBed.inject(DocumentListViewService)
+    settingsService = TestBed.inject(SettingsService)
+    router = TestBed.inject(Router)
+  })
+
+  afterEach(() => {
+    httpTestingController.verify()
+    sessionStorage.clear()
+  })
+
+  afterAll(() => {
+    subscriptions?.forEach((subscription) => {
+      subscription.unsubscribe()
+    })
+  })
+
+  it('should reload the list', () => {
+    expect(documentListViewService.currentPage).toEqual(1)
+    documentListViewService.reload()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush(full_results)
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/selection_data/`
+    )
+    expect(req.request.method).toEqual('GET')
+    expect(documentListViewService.isReloading).toBeFalsy()
+    expect(documentListViewService.activeSavedViewId).toBeNull()
+    expect(documentListViewService.activeSavedViewTitle).toBeNull()
+    expect(documentListViewService.collectionSize).toEqual(documents.length)
+    expect(documentListViewService.getLastPage()).toEqual(1)
+  })
+
+  it('should handle error on page request out of range', () => {
+    documentListViewService.currentPage = 50
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush([], { status: 404, statusText: 'Unexpected error' })
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    expect(documentListViewService.currentPage).toEqual(1)
+  })
+
+  it('should handle error on filtering request', () => {
+    documentListViewService.currentPage = 1
+    const tags__id__in = 'hello'
+    const filterRulesAny = [
+      {
+        rule_type: FILTER_HAS_TAGS_ANY,
+        value: tags__id__in,
+      },
+    ]
+    documentListViewService.filterRules = filterRulesAny
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush(
+      { archive_serial_number: 'hello' },
+      { status: 404, statusText: 'Unexpected error' }
+    )
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    // reset the list
+    documentListViewService.filterRules = []
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+  })
+
+  it('should support setting sort', () => {
+    expect(documentListViewService.sortField).toEqual('created')
+    expect(documentListViewService.sortReverse).toBeTruthy()
+    documentListViewService.setSort('added', false)
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    expect(documentListViewService.sortField).toEqual('added')
+    expect(documentListViewService.sortReverse).toBeFalsy()
+
+    documentListViewService.sortField = 'created'
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true`
+    )
+    expect(documentListViewService.sortField).toEqual('created')
+    documentListViewService.sortReverse = true
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    expect(documentListViewService.sortReverse).toBeTruthy()
+  })
+
+  it('should load from query params', () => {
+    expect(documentListViewService.currentPage).toEqual(1)
+    const page = 2
+    const sort = 'added'
+    const reverse = true
+    const params: Params = {
+      page,
+      sort,
+      reverse,
+    }
+    documentListViewService.loadFromQueryParams(convertToParamMap(params))
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=${page}&page_size=${
+        documentListViewService.currentPageSize
+      }&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    expect(documentListViewService.currentPage).toEqual(page)
+    expect(documentListViewService.filterRules).toEqual([])
+  })
+
+  it('should load filter rules from query params', () => {
+    const sort = 'added'
+    const reverse = true
+    const params: Params = {
+      sort,
+      reverse,
+      tags__id__all,
+    }
+    documentListViewService.loadFromQueryParams(convertToParamMap(params))
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
+    )
+    expect(req.request.method).toEqual('GET')
+    expect(documentListViewService.filterRules).toEqual([
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: tags__id__all,
+      },
+    ])
+    req.flush(full_results)
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/selection_data/`
+    )
+  })
+
+  it('should use filter rules to update query params', () => {
+    documentListViewService.filterRules = filterRules
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should support quick filter', () => {
+    documentListViewService.quickFilter(filterRules)
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should support loading saved view', () => {
+    const routerSpy = jest.spyOn(router, 'navigate')
+    documentListViewService.activateSavedView(view)
+    expect(routerSpy).toHaveBeenCalledWith(['view', view.id])
+    documentListViewService.activateSavedView(null)
+  })
+
+  it('should support loading saved view view query params', () => {
+    const page = 2
+    const params: Params = {
+      view: view.id,
+      page,
+    }
+    documentListViewService.activateSavedViewWithQueryParams(
+      view,
+      convertToParamMap(params)
+    )
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.currentPageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
+    )
+    expect(req.request.method).toEqual('GET')
+    // reset the list
+    documentListViewService.currentPage = 1
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
+    )
+    documentListViewService.filterRules = []
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true`
+    )
+    documentListViewService.sortField = 'created'
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    documentListViewService.activateSavedView(null)
+  })
+
+  it('should support navigating next / previous', () => {
+    documentListViewService.filterRules = []
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(documentListViewService.currentPage).toEqual(1)
+    documentListViewService.currentPageSize = 3
+    documentListViewService.reload()
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush({
+      count: 3,
+      results: documents.slice(0, 3),
+    })
+    httpTestingController
+      .expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
+      .flush([])
+    expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
+    expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
+    documentListViewService.getNext(documents[0].id).subscribe((docId) => {
+      expect(docId).toEqual(documents[1].id)
+    })
+    documentListViewService.getNext(documents[2].id).subscribe((docId) => {
+      expect(docId).toEqual(documents[3].id)
+      expect(documentListViewService.currentPage).toEqual(2)
+    })
+    documentListViewService.getPrevious(documents[3].id).subscribe((docId) => {
+      expect(docId).toEqual(documents[2].id)
+      expect(documentListViewService.currentPage).toEqual(1)
+    })
+  })
+
+  it('should update page size from settings', () => {
+    settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 10)
+    documentListViewService.updatePageSize()
+    expect(documentListViewService.currentPageSize).toEqual(10)
+  })
+
+  it('should support select a document', () => {
+    documentListViewService.reload()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush(full_results)
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/selection_data/`
+    )
+    documentListViewService.toggleSelected(documents[0])
+    expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
+    documentListViewService.toggleSelected(documents[0])
+    expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
+  })
+
+  it('should support select all', () => {
+    documentListViewService.selectAll()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush(full_results)
+    expect(documentListViewService.selected.size).toEqual(documents.length)
+    expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
+    documentListViewService.selectNone()
+  })
+
+  it('should support select page', () => {
+    documentListViewService.currentPageSize = 3
+    documentListViewService.reload()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush({
+      count: 3,
+      results: documents.slice(0, 3),
+    })
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/selection_data/`
+    )
+    documentListViewService.selectPage()
+    expect(documentListViewService.selected.size).toEqual(3)
+    expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
+  })
+
+  it('should support select range', () => {
+    documentListViewService.reload()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush(full_results)
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/selection_data/`
+    )
+    documentListViewService.toggleSelected(documents[0])
+    expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
+    documentListViewService.selectRangeTo(documents[2])
+    expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
+    documentListViewService.selectRangeTo(documents[4])
+    expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
+  })
+
+  it('should support selection range reduction', () => {
+    documentListViewService.selectAll()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush(full_results)
+    expect(documentListViewService.selected.size).toEqual(6)
+
+    documentListViewService.filterRules = filterRules
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
+    )
+    const reqs = httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
+    )
+    reqs[0].flush({
+      count: 3,
+      results: documents.slice(0, 3),
+    })
+    expect(documentListViewService.selected.size).toEqual(3)
+  })
+})
index 34404d357a4b453967e041595036c7e80e8a268d..730bea23aa96da6879df618f1fc736c9200fab6e 100644 (file)
@@ -1,12 +1,12 @@
 import { Injectable } from '@angular/core'
 import { ParamMap, Router } from '@angular/router'
 import { Observable, first } from 'rxjs'
+import { FilterRule } from '../data/filter-rule'
 import {
   filterRulesDiffer,
   cloneFilterRules,
-  FilterRule,
   isFullTextFilterRule,
-} from '../data/filter-rule'
+} from '../utils/filter-rules'
 import { PaperlessDocument } from '../data/paperless-document'
 import { PaperlessSavedView } from '../data/paperless-saved-view'
 import { SETTINGS_KEYS } from '../data/paperless-uisettings'
diff --git a/src-ui/src/app/services/open-documents.service.spec.ts b/src-ui/src/app/services/open-documents.service.spec.ts
new file mode 100644 (file)
index 0000000..3c8e29e
--- /dev/null
@@ -0,0 +1,224 @@
+import { TestBed } from '@angular/core/testing'
+import { OpenDocumentsService } from './open-documents.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { environment } from 'src/environments/environment'
+import { Subscription } from 'rxjs'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
+import { OPEN_DOCUMENT_SERVICE } from '../data/storage-keys'
+
+const documents = [
+  {
+    id: 1,
+    title: 'Doc 1',
+    content: 'some content',
+    tags: [1, 2, 3],
+    correspondent: 11,
+    document_type: 3,
+    storage_path: 8,
+  },
+  {
+    id: 2,
+    title: 'Doc 2',
+    content: 'some content',
+  },
+  {
+    id: 3,
+    title: 'Doc 3',
+    content: 'some content',
+  },
+  {
+    id: 4,
+    title: 'Doc 4',
+    content: 'some content',
+  },
+  {
+    id: 5,
+    title: 'Doc 5',
+    content: 'some content',
+  },
+  {
+    id: 6,
+    title: 'Doc 6',
+    content: 'some content',
+  },
+]
+
+describe('OpenDocumentsService', () => {
+  let httpTestingController: HttpTestingController
+  let openDocumentsService: OpenDocumentsService
+  let modalService: NgbModal
+  let subscriptions: Subscription[] = []
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [OpenDocumentsService, NgbModal],
+      imports: [HttpClientTestingModule],
+      declarations: [ConfirmDialogComponent],
+    })
+
+    sessionStorage.clear()
+    httpTestingController = TestBed.inject(HttpTestingController)
+    openDocumentsService = TestBed.inject(OpenDocumentsService)
+    modalService = TestBed.inject(NgbModal)
+  })
+
+  afterEach(() => {
+    httpTestingController.verify()
+  })
+
+  afterAll(() => {
+    subscriptions?.forEach((subscription) => {
+      subscription.unsubscribe()
+    })
+  })
+
+  it('should open documents', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[0]).subscribe()
+    )
+    expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
+    const doc = openDocumentsService.getOpenDocument(documents[0].id)
+    expect(doc.id).toEqual(documents[0].id)
+  })
+
+  it('should limit number of open documents', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[0]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[1]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[2]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[3]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[4]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[5]).subscribe()
+    )
+    expect(openDocumentsService.getOpenDocuments()).toHaveLength(5)
+  })
+
+  it('should close documents', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[0]).subscribe()
+    )
+    expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
+    openDocumentsService.closeDocument(documents[0])
+    expect(openDocumentsService.getOpenDocuments()).toHaveLength(0)
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[0]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[1]).subscribe()
+    )
+    expect(openDocumentsService.getOpenDocuments()).toHaveLength(2)
+    subscriptions.push(openDocumentsService.closeAll().subscribe())
+  })
+
+  it('should allow set dirty status, warn on close', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[0]).subscribe()
+    )
+    openDocumentsService.setDirty(documents[0], false)
+    expect(openDocumentsService.hasDirty()).toBeFalsy()
+    openDocumentsService.setDirty(documents[0], true)
+    expect(openDocumentsService.hasDirty()).toBeTruthy()
+    const modalSpy = jest.spyOn(modalService, 'open')
+    subscriptions.push(
+      openDocumentsService.closeDocument(documents[0]).subscribe()
+    )
+    expect(modalSpy).toHaveBeenCalled()
+  })
+
+  it('should allow set dirty status, warn on closeAll', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[0]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[1]).subscribe()
+    )
+    openDocumentsService.setDirty(documents[0], true)
+    expect(openDocumentsService.hasDirty()).toBeTruthy()
+    const modalSpy = jest.spyOn(modalService, 'open')
+    subscriptions.push(openDocumentsService.closeAll().subscribe())
+    expect(modalSpy).toHaveBeenCalled()
+  })
+
+  it('should load open documents from localStorage', () => {
+    sessionStorage.setItem(
+      OPEN_DOCUMENT_SERVICE.DOCUMENTS,
+      JSON.stringify(documents)
+    )
+    const testOpenDocumentsService = new OpenDocumentsService(
+      null,
+      modalService
+    )
+    expect(testOpenDocumentsService.getOpenDocuments()).toHaveLength(
+      documents.length
+    )
+  })
+
+  it('should remove open documents from localStorage on error', () => {
+    sessionStorage.setItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS, 'hello world')
+    const testOpenDocumentsService = new OpenDocumentsService(
+      null,
+      modalService
+    )
+    expect(testOpenDocumentsService.getOpenDocuments()).toHaveLength(0)
+    expect(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)).toBeNull()
+  })
+
+  it('should save open documents to localStorage', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[0]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[1]).subscribe()
+    )
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[2]).subscribe()
+    )
+    openDocumentsService.save()
+    const localStorageDocs = JSON.parse(
+      sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)
+    )
+    expect(localStorageDocs).toContainEqual(documents[0])
+    expect(localStorageDocs).toContainEqual(documents[1])
+    expect(localStorageDocs).toContainEqual(documents[2])
+  })
+
+  it('should refresh documents', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[1]).subscribe()
+    )
+    openDocumentsService.refreshDocument(documents[1].id)
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/${documents[1].id}/?full_perms=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.flush(documents[1])
+    expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
+  })
+
+  it('should handle error on refresh documents', () => {
+    subscriptions.push(
+      openDocumentsService.openDocument(documents[1]).subscribe()
+    )
+    openDocumentsService.refreshDocument(documents[1].id)
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/${documents[1].id}/?full_perms=true`
+    )
+    expect(req.request.method).toEqual('GET')
+    req.error(new ErrorEvent('timeout'))
+    expect(openDocumentsService.getOpenDocuments()).toHaveLength(0)
+  })
+})
diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts
new file mode 100644 (file)
index 0000000..080326b
--- /dev/null
@@ -0,0 +1,405 @@
+import { TestBed } from '@angular/core/testing'
+import {
+  PermissionAction,
+  PermissionType,
+  PermissionsService,
+} from './permissions.service'
+import { PaperlessDocument } from '../data/paperless-document'
+
+describe('PermissionsService', () => {
+  let permissionsService: PermissionsService
+
+  const docUnowned: PaperlessDocument = {
+    title: 'Doc title',
+    owner: null,
+  }
+
+  const docOwned: PaperlessDocument = {
+    title: 'Doc title 2',
+    owner: 1,
+  }
+
+  const docNotOwned: PaperlessDocument = {
+    title: 'Doc title 3',
+    owner: 2,
+  }
+
+  const docUserViewGranted: PaperlessDocument = {
+    title: 'Doc title 4',
+    owner: 2,
+    permissions: {
+      view: {
+        users: [1],
+        groups: [],
+      },
+      change: {
+        users: [],
+        groups: [],
+      },
+    },
+  }
+
+  const docUserEditGranted: PaperlessDocument = {
+    title: 'Doc title 5',
+    owner: 2,
+    permissions: {
+      view: {
+        users: [1],
+        groups: [],
+      },
+      change: {
+        users: [1],
+        groups: [],
+      },
+    },
+  }
+
+  const docGroupViewGranted: PaperlessDocument = {
+    title: 'Doc title 4',
+    owner: 2,
+    permissions: {
+      view: {
+        users: [],
+        groups: [1],
+      },
+      change: {
+        users: [],
+        groups: [],
+      },
+    },
+  }
+
+  const docGroupEditGranted: PaperlessDocument = {
+    title: 'Doc title 5',
+    owner: 2,
+    permissions: {
+      view: {
+        users: [],
+        groups: [1],
+      },
+      change: {
+        users: [],
+        groups: [1],
+      },
+    },
+  }
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [PermissionsService],
+    })
+
+    permissionsService = TestBed.inject(PermissionsService)
+  })
+
+  it('correctly interpolates action codes to keys', () => {
+    expect(
+      permissionsService.getPermissionCode(
+        PermissionAction.View,
+        PermissionType.Document
+      )
+    ).toEqual('view_document')
+    expect(permissionsService.getPermissionKeys('view_document')).toEqual({
+      actionKey: 'View', // PermissionAction.View
+      typeKey: 'Document', // PermissionType.Document
+    })
+  })
+
+  it('correctly checks explicit global permissions', () => {
+    permissionsService.initialize(
+      [
+        'change_savedview',
+        'change_schedule',
+        'change_failure',
+        'delete_token',
+        'add_mailrule',
+        'view_failure',
+        'view_groupresult',
+        'add_note',
+        'change_taskresult',
+        'view_tag',
+        'view_user',
+        'add_tag',
+        'change_processedmail',
+        'change_session',
+        'view_taskattributes',
+        'delete_groupresult',
+        'delete_correspondent',
+        'delete_schedule',
+        'delete_contenttype',
+        'view_chordcounter',
+        'view_success',
+        'delete_documenttype',
+        'add_tokenproxy',
+        'delete_paperlesstask',
+        'add_log',
+        'view_mailaccount',
+        'add_uisettings',
+        'view_savedview',
+        'view_uisettings',
+        'delete_storagepath',
+        'delete_frontendsettings',
+        'change_paperlesstask',
+        'view_taskresult',
+        'delete_processedmail',
+        'view_processedmail',
+        'view_session',
+        'delete_chordcounter',
+        'view_note',
+        'delete_session',
+        'view_document',
+        'change_mailaccount',
+        'delete_taskattributes',
+        'add_groupobjectpermission',
+        'view_mailrule',
+        'change_savedviewfilterrule',
+        'change_log',
+        'change_comment',
+        'add_mailaccount',
+        'add_frontendsettings',
+        'add_userobjectpermission',
+        'delete_note',
+        'view_token',
+        'add_failure',
+        'delete_user',
+        'add_success',
+        'view_ormq',
+        'view_tokenproxy',
+        'delete_uisettings',
+        'change_groupobjectpermission',
+        'add_logentry',
+        'add_ormq',
+        'view_frontendsettings',
+        'view_schedule',
+        'change_taskattributes',
+        'view_documenttype',
+        'view_logentry',
+        'change_correspondent',
+        'add_groupresult',
+        'delete_groupobjectpermission',
+        'change_mailrule',
+        'change_permission',
+        'delete_log',
+        'view_userobjectpermission',
+        'view_correspondent',
+        'delete_document',
+        'change_uisettings',
+        'change_storagepath',
+        'change_document',
+        'delete_tokenproxy',
+        'change_note',
+        'delete_permission',
+        'change_contenttype',
+        'add_token',
+        'change_success',
+        'delete_logentry',
+        'view_savedviewfilterrule',
+        'delete_task',
+        'add_savedview',
+        'add_paperlesstask',
+        'add_task',
+        'change_documenttype',
+        'add_documenttype',
+        'change_token',
+        'view_task',
+        'view_permission',
+        'change_task',
+        'delete_userobjectpermission',
+        'change_group',
+        'add_group',
+        'change_tag',
+        'change_chordcounter',
+        'add_storagepath',
+        'delete_group',
+        'add_taskattributes',
+        'delete_mailaccount',
+        'delete_tag',
+        'add_schedule',
+        'delete_failure',
+        'delete_mailrule',
+        'add_savedviewfilterrule',
+        'change_ormq',
+        'change_logentry',
+        'add_taskresult',
+        'view_group',
+        'delete_comment',
+        'add_contenttype',
+        'add_document',
+        'change_tokenproxy',
+        'delete_success',
+        'add_comment',
+        'delete_ormq',
+        'add_processedmail',
+        'view_paperlesstask',
+        'delete_savedview',
+        'change_user',
+        'add_session',
+        'view_groupobjectpermission',
+        'add_user',
+        'add_correspondent',
+        'delete_taskresult',
+        'view_contenttype',
+        'view_storagepath',
+        'add_permission',
+        'change_userobjectpermission',
+        'delete_savedviewfilterrule',
+        'change_groupresult',
+        'add_chordcounter',
+        'view_log',
+        'view_comment',
+        'change_frontendsettings',
+      ],
+      {
+        username: 'testuser',
+        last_name: 'User',
+        first_name: 'Test',
+      }
+    )
+
+    Object.values(PermissionType).forEach((type) => {
+      Object.values(PermissionAction).forEach((action) => {
+        expect(permissionsService.currentUserCan(action, type)).toBeTruthy()
+      })
+    })
+
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+    })
+
+    Object.values(PermissionType).forEach((type) => {
+      Object.values(PermissionAction).forEach((action) => {
+        expect(permissionsService.currentUserCan(action, type)).toBeFalsy()
+      })
+    })
+  })
+
+  it('correctly checks global permissions for superuser', () => {
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      is_superuser: true,
+    })
+
+    Object.values(PermissionType).forEach((type) => {
+      Object.values(PermissionAction).forEach((action) => {
+        expect(permissionsService.currentUserCan(action, type)).toBeTruthy()
+      })
+    })
+  })
+
+  it('correctly checks object owner permissions', () => {
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      id: 1,
+    })
+
+    expect(permissionsService.currentUserOwnsObject(docUnowned)).toBeTruthy()
+    expect(permissionsService.currentUserOwnsObject(docOwned)).toBeTruthy()
+    expect(permissionsService.currentUserOwnsObject(docNotOwned)).toBeFalsy()
+  })
+
+  it('correctly checks object owner permissions for superuser', () => {
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      id: 1,
+      is_superuser: true,
+    })
+
+    expect(permissionsService.currentUserOwnsObject(docUnowned)).toBeTruthy()
+    expect(permissionsService.currentUserOwnsObject(docOwned)).toBeTruthy()
+    expect(permissionsService.currentUserOwnsObject(docNotOwned)).toBeTruthy()
+  })
+
+  it('correctly checks granted object permissions', () => {
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      id: 1,
+    })
+
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.View,
+        docNotOwned
+      )
+    ).toBeFalsy()
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.View,
+        docUserViewGranted
+      )
+    ).toBeTruthy()
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.Change,
+        docUserEditGranted
+      )
+    ).toBeTruthy()
+  })
+
+  it('correctly checks granted object permissions for superuser', () => {
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      id: 1,
+      is_superuser: true,
+    })
+
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.View,
+        docNotOwned
+      )
+    ).toBeTruthy()
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.View,
+        docUserViewGranted
+      )
+    ).toBeTruthy()
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.Change,
+        docUserEditGranted
+      )
+    ).toBeTruthy()
+  })
+
+  it('correctly checks granted object permissions from group', () => {
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      id: 1,
+      groups: [1],
+    })
+
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.View,
+        docNotOwned
+      )
+    ).toBeFalsy()
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.View,
+        docGroupViewGranted
+      )
+    ).toBeTruthy()
+    expect(
+      permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.Change,
+        docGroupEditGranted
+      )
+    ).toBeTruthy()
+  })
+})
index 1e7b0d03185db4c4a668910a78f04e59a64cde75..a14e4bc1ff67bdcb1f8253cc9c48111ab98cd3bf 100644 (file)
@@ -42,7 +42,10 @@ export class PermissionsService {
     action: PermissionAction,
     type: PermissionType
   ): boolean {
-    return this.permissions.includes(this.getPermissionCode(action, type))
+    return (
+      this.currentUser?.is_superuser ||
+      this.permissions?.includes(this.getPermissionCode(action, type))
+    )
   }
 
   public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
diff --git a/src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts b/src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts
new file mode 100644 (file)
index 0000000..e4ec93a
--- /dev/null
@@ -0,0 +1,60 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { AbstractNameFilterService } from './abstract-name-filter-service'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+
+let httpTestingController: HttpTestingController
+let service: AbstractNameFilterService<any>
+let subscription: Subscription
+
+export const commonAbstractNameFilterPaperlessServiceTests = (
+  endpoint,
+  ServiceClass
+) => {
+  commonAbstractPaperlessServiceTests(endpoint, ServiceClass)
+
+  describe(`Common name filter service tests for ${endpoint}`, () => {
+    test('should call appropriate api endpoint for list filtering', () => {
+      const page = 2
+      const pageSize = 50
+      const sortField = 'name'
+      const sortReverse = true
+      const nameFilter = 'hello'
+      const fullPerms = true
+      subscription = service
+        .listFiltered(
+          page,
+          pageSize,
+          sortField,
+          sortReverse,
+          nameFilter,
+          fullPerms
+        )
+        .subscribe()
+      const req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/?page=${page}&page_size=${pageSize}&ordering=-${sortField}&name__icontains=${nameFilter}&full_perms=true`
+      )
+      expect(req.request.method).toEqual('GET')
+      req.flush([])
+    })
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+    // TestBed.configureTestingModule({
+    //   providers: [ServiceClass],
+    //   imports: [HttpClientTestingModule],
+    //   teardown: { destroyAfterEach: true },
+    // })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(ServiceClass)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    // httpTestingController.verify()
+  })
+}
diff --git a/src-ui/src/app/services/rest/abstract-paperless-service.spec.ts b/src-ui/src/app/services/rest/abstract-paperless-service.spec.ts
new file mode 100644 (file)
index 0000000..92ff923
--- /dev/null
@@ -0,0 +1,116 @@
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { AbstractPaperlessService } from './abstract-paperless-service'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+
+let httpTestingController: HttpTestingController
+let service: AbstractPaperlessService<any>
+let subscription: Subscription
+
+export const commonAbstractPaperlessServiceTests = (endpoint, ServiceClass) => {
+  describe(`Common service tests for ${endpoint}`, () => {
+    test('should call appropriate api endpoint for list all', () => {
+      subscription = service.listAll().subscribe()
+      const req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+      )
+      expect(req.request.method).toEqual('GET')
+      req.flush([])
+    })
+
+    test('should call appropriate api endpoint for get a single object', () => {
+      const id = 0
+      subscription = service.get(id).subscribe()
+      const req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/${id}/`
+      )
+      expect(req.request.method).toEqual('GET')
+      req.flush([])
+    })
+
+    test('should call appropriate api endpoint for create a single object', () => {
+      const o = {
+        name: 'Name',
+      }
+      subscription = service.create(o).subscribe()
+      const req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/`
+      )
+      expect(req.request.method).toEqual('POST')
+      req.flush([])
+    })
+
+    test('should call appropriate api endpoint for delete a single object', () => {
+      const id = 10
+      const o = {
+        name: 'Name',
+        id,
+      }
+      subscription = service.delete(o).subscribe()
+      const req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/${id}/`
+      )
+      expect(req.request.method).toEqual('DELETE')
+      req.flush([])
+    })
+
+    test('should call appropriate api endpoint for update a single object', () => {
+      const id = 10
+      const o = {
+        name: 'Name',
+        id,
+      }
+
+      // some services need to call listAll first
+      subscription = service.listAll().subscribe()
+      let req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+      )
+      req.flush({
+        results: [o],
+      })
+      subscription.unsubscribe()
+
+      subscription = service.update(o).subscribe()
+      req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/${id}/`
+      )
+      expect(req.request.method).toEqual('PUT')
+      req.flush([])
+    })
+
+    test('should call appropriate api endpoint for patch a single object', () => {
+      const id = 10
+      const o = {
+        name: 'Name',
+        id,
+      }
+      subscription = service.patch(o).subscribe()
+      const req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}${endpoint}/${id}/`
+      )
+      expect(req.request.method).toEqual('PATCH')
+      req.flush([])
+    })
+  })
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [ServiceClass],
+      imports: [HttpClientTestingModule],
+      teardown: { destroyAfterEach: true },
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(ServiceClass)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    // httpTestingController.verify()
+  })
+}
index 9a5664c9d2c4de6410697ee125bfba9f679cd648..96de58e2ded375ae59d3081be3a298570297eb18 100644 (file)
@@ -10,9 +10,9 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
 
   constructor(protected http: HttpClient, private resourceName: string) {}
 
-  protected getResourceUrl(id?: number, action?: string): string {
+  protected getResourceUrl(id: number = null, action: string = null): string {
     let url = `${this.baseUrl}${this.resourceName}/`
-    if (id) {
+    if (id !== null) {
       url += `${id}/`
     }
     if (action) {
diff --git a/src-ui/src/app/services/rest/correspondent.service.spec.ts b/src-ui/src/app/services/rest/correspondent.service.spec.ts
new file mode 100644 (file)
index 0000000..94cfa9c
--- /dev/null
@@ -0,0 +1,7 @@
+import { CorrespondentService } from './correspondent.service'
+import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+
+commonAbstractNameFilterPaperlessServiceTests(
+  'correspondents',
+  CorrespondentService
+)
diff --git a/src-ui/src/app/services/rest/document-notes.service.spec.ts b/src-ui/src/app/services/rest/document-notes.service.spec.ts
new file mode 100644 (file)
index 0000000..c076230
--- /dev/null
@@ -0,0 +1,79 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+import { MailFilterAttachmentType } from 'src/app/data/paperless-mail-rule'
+import { MailMetadataTitleOption } from 'src/app/data/paperless-mail-rule'
+import { MailAction } from 'src/app/data/paperless-mail-rule'
+import { DocumentNotesService } from './document-notes.service'
+
+let httpTestingController: HttpTestingController
+let service: DocumentNotesService
+let subscription: Subscription
+const documentId = 12
+const endpoint = 'documents'
+const endpoint2 = 'notes'
+const notes = [
+  {
+    created: new Date(),
+    note: 'contents 1',
+    user: 1,
+  },
+  {
+    created: new Date(),
+    note: 'contents 2',
+    user: 1,
+  },
+  {
+    created: new Date(),
+    note: 'contents 3',
+    user: 2,
+  },
+]
+
+// run common tests
+commonAbstractPaperlessServiceTests(endpoint, DocumentNotesService)
+
+describe(`Additional service tests for DocumentNotesService`, () => {
+  test('should call correct api endpoint on get notes', () => {
+    subscription = service.getNotes(documentId).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documentId}/${endpoint2}/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  test('should call correct api endpoint on add note', () => {
+    const content = 'some new text'
+    subscription = service.addNote(documentId, content).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documentId}/${endpoint2}/`
+    )
+    expect(req.request.method).toEqual('POST')
+    expect(req.request.body).toEqual({
+      note: content,
+    })
+  })
+
+  test('should call correct api endpoint on delete note', () => {
+    const noteId = 11
+    subscription = service.deleteNote(documentId, noteId).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documentId}/${endpoint2}/?id=${noteId}`
+    )
+    expect(req.request.method).toEqual('DELETE')
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(DocumentNotesService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+})
diff --git a/src-ui/src/app/services/rest/document-type.service.spec.ts b/src-ui/src/app/services/rest/document-type.service.spec.ts
new file mode 100644 (file)
index 0000000..9fc9151
--- /dev/null
@@ -0,0 +1,7 @@
+import { DocumentTypeService } from './document-type.service'
+import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+
+commonAbstractNameFilterPaperlessServiceTests(
+  'document_types',
+  DocumentTypeService
+)
diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts
new file mode 100644 (file)
index 0000000..be1128d
--- /dev/null
@@ -0,0 +1,247 @@
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { DocumentService } from './document.service'
+import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
+
+let httpTestingController: HttpTestingController
+let service: DocumentService
+let subscription: Subscription
+const endpoint = 'documents'
+const documents = [
+  {
+    id: 1,
+    title: 'Doc 1',
+    content: 'some content',
+    tags: [1, 2, 3],
+    correspondent: 11,
+    document_type: 3,
+    storage_path: 8,
+  },
+  {
+    id: 2,
+    title: 'Doc 2',
+    content: 'some content',
+  },
+  {
+    id: 3,
+    title: 'Doc 3',
+    content: 'some content',
+  },
+]
+
+describe(`DocumentService`, () => {
+  // common tests e.g. commonAbstractPaperlessServiceTests differ slightly
+  it('should call appropriate api endpoint for list all', () => {
+    subscription = service.listAll().subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should call appropriate api endpoint for get a single document', () => {
+    subscription = service.get(documents[0].id).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/?full_perms=true`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should call appropriate api endpoint for create a single document', () => {
+    subscription = service.create(documents[0]).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/`
+    )
+    expect(req.request.method).toEqual('POST')
+  })
+
+  it('should call appropriate api endpoint for delete a single document', () => {
+    subscription = service.delete(documents[0]).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/`
+    )
+    expect(req.request.method).toEqual('DELETE')
+  })
+
+  it('should call appropriate api endpoint for update a single document', () => {
+    subscription = service.update(documents[0]).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/`
+    )
+    expect(req.request.method).toEqual('PUT')
+  })
+
+  it('should call appropriate api endpoint for patch a single document', () => {
+    subscription = service.patch(documents[0]).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/`
+    )
+    expect(req.request.method).toEqual('PATCH')
+  })
+
+  it('should call appropriate api endpoint for listing all documents ids in a filter set', () => {
+    subscription = service
+      .listAllFilteredIds([
+        {
+          rule_type: FILTER_TITLE,
+          value: 'apple',
+        },
+      ])
+      .subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000&fields=id&title__icontains=apple`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should call appropriate api endpoint for uploading a document', () => {
+    subscription = service.uploadDocument(documents[0]).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/post_document/`
+    )
+    expect(req.request.method).toEqual('POST')
+  })
+
+  it('should call appropriate api endpoint for getting metadata', () => {
+    subscription = service.getMetadata(documents[0].id).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/metadata/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should call appropriate api endpoint for getting selection data', () => {
+    const ids = [documents[0].id]
+    subscription = service.getSelectionData(ids).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/selection_data/`
+    )
+    expect(req.request.method).toEqual('POST')
+    expect(req.request.body).toEqual({
+      documents: ids,
+    })
+  })
+
+  it('should call appropriate api endpoint for getting suggestions', () => {
+    subscription = service.getSuggestions(documents[0].id).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/suggestions/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should call appropriate api endpoint for bulk download', () => {
+    const ids = [1, 2, 3]
+    const content = 'both'
+    const useFilenameFormatting = false
+    subscription = service
+      .bulkDownload(ids, content, useFilenameFormatting)
+      .subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/bulk_download/`
+    )
+    expect(req.request.method).toEqual('POST')
+    expect(req.request.body).toEqual({
+      documents: ids,
+      content,
+      follow_formatting: useFilenameFormatting,
+    })
+  })
+
+  it('should call appropriate api endpoint for bulk edit', () => {
+    const ids = [1, 2, 3]
+    const method = 'modify_tags'
+    const parameters = {
+      add_tags: [15],
+      remove_tags: [6],
+    }
+    subscription = service.bulkEdit(ids, method, parameters).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/bulk_edit/`
+    )
+    expect(req.request.method).toEqual('POST')
+    expect(req.request.body).toEqual({
+      documents: ids,
+      method,
+      parameters,
+    })
+  })
+
+  it('should return the correct preview URL for a single document', () => {
+    let url = service.getPreviewUrl(documents[0].id)
+    expect(url).toEqual(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/preview/`
+    )
+    url = service.getPreviewUrl(documents[0].id, true)
+    expect(url).toEqual(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/preview/?original=true`
+    )
+  })
+
+  it('should return the correct thumb URL for a single document', () => {
+    let url = service.getThumbUrl(documents[0].id)
+    expect(url).toEqual(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/thumb/`
+    )
+  })
+
+  it('should return the correct download URL for a single document', () => {
+    let url = service.getDownloadUrl(documents[0].id)
+    expect(url).toEqual(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/download/`
+    )
+    url = service.getDownloadUrl(documents[0].id, true)
+    expect(url).toEqual(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/download/?original=true`
+    )
+  })
+
+  it('should add observables to document', () => {
+    subscription = service
+      .listFiltered(1, 25, 'title', false, [])
+      .subscribe((result) => {
+        expect(result.results).toHaveLength(3)
+        const doc = result.results[0]
+        expect(doc.correspondent$).not.toBeNull()
+        expect(doc.document_type$).not.toBeNull()
+        expect(doc.tags$).not.toBeNull()
+        expect(doc.storage_path$).not.toBeNull()
+      })
+    httpTestingController
+      .expectOne(
+        `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=25&ordering=title`
+      )
+      .flush({
+        results: documents,
+      })
+  })
+
+  it('should set search query', () => {
+    const searchQuery = 'hello'
+    service.searchQuery = searchQuery
+    let url = service.getPreviewUrl(documents[0].id)
+    expect(url).toEqual(
+      `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/preview/#search="${searchQuery}"`
+    )
+  })
+})
+
+beforeEach(() => {
+  TestBed.configureTestingModule({
+    providers: [DocumentService],
+    imports: [HttpClientTestingModule],
+  })
+
+  httpTestingController = TestBed.inject(HttpTestingController)
+  service = TestBed.inject(DocumentService)
+})
+
+afterEach(() => {
+  subscription?.unsubscribe()
+  httpTestingController.verify()
+})
diff --git a/src-ui/src/app/services/rest/group.service.spec.ts b/src-ui/src/app/services/rest/group.service.spec.ts
new file mode 100644 (file)
index 0000000..98183b5
--- /dev/null
@@ -0,0 +1,192 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { GroupService } from './group.service'
+import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+
+let httpTestingController: HttpTestingController
+let service: GroupService
+let subscription: Subscription
+const endpoint = 'groups'
+const group = {
+  name: 'Group Name',
+  id: 1,
+  user_count: 1,
+  permissions: [
+    'change_savedview',
+    'change_schedule',
+    'change_failure',
+    'delete_token',
+    'add_mailrule',
+    'view_failure',
+    'view_groupresult',
+    'add_note',
+    'change_taskresult',
+    'view_tag',
+    'view_user',
+    'add_tag',
+    'change_processedmail',
+    'change_session',
+    'view_taskattributes',
+    'delete_groupresult',
+    'delete_correspondent',
+    'delete_schedule',
+    'delete_contenttype',
+    'view_chordcounter',
+    'view_success',
+    'delete_documenttype',
+    'add_tokenproxy',
+    'delete_paperlesstask',
+    'add_log',
+    'view_mailaccount',
+    'add_uisettings',
+    'view_savedview',
+    'view_uisettings',
+    'delete_storagepath',
+    'delete_frontendsettings',
+    'change_paperlesstask',
+    'view_taskresult',
+    'delete_processedmail',
+    'view_processedmail',
+    'view_session',
+    'delete_chordcounter',
+    'view_note',
+    'delete_session',
+    'view_document',
+    'change_mailaccount',
+    'delete_taskattributes',
+    'add_groupobjectpermission',
+    'view_mailrule',
+    'change_savedviewfilterrule',
+    'change_log',
+    'change_comment',
+    'add_mailaccount',
+    'add_frontendsettings',
+    'add_userobjectpermission',
+    'delete_note',
+    'view_token',
+    'add_failure',
+    'delete_user',
+    'add_success',
+    'view_ormq',
+    'view_tokenproxy',
+    'delete_uisettings',
+    'change_groupobjectpermission',
+    'add_logentry',
+    'add_ormq',
+    'view_frontendsettings',
+    'view_schedule',
+    'change_taskattributes',
+    'view_documenttype',
+    'view_logentry',
+    'change_correspondent',
+    'add_groupresult',
+    'delete_groupobjectpermission',
+    'change_mailrule',
+    'change_permission',
+    'delete_log',
+    'view_userobjectpermission',
+    'view_correspondent',
+    'delete_document',
+    'change_uisettings',
+    'change_storagepath',
+    'change_document',
+    'delete_tokenproxy',
+    'change_note',
+    'delete_permission',
+    'change_contenttype',
+    'add_token',
+    'change_success',
+    'delete_logentry',
+    'view_savedviewfilterrule',
+    'delete_task',
+    'add_savedview',
+    'add_paperlesstask',
+    'add_task',
+    'change_documenttype',
+    'add_documenttype',
+    'change_token',
+    'view_task',
+    'view_permission',
+    'change_task',
+    'delete_userobjectpermission',
+    'change_group',
+    'add_group',
+    'change_tag',
+    'change_chordcounter',
+    'add_storagepath',
+    'delete_group',
+    'add_taskattributes',
+    'delete_mailaccount',
+    'delete_tag',
+    'add_schedule',
+    'delete_failure',
+    'delete_mailrule',
+    'add_savedviewfilterrule',
+    'change_ormq',
+    'change_logentry',
+    'add_taskresult',
+    'view_group',
+    'delete_comment',
+    'add_contenttype',
+    'add_document',
+    'change_tokenproxy',
+    'delete_success',
+    'add_comment',
+    'delete_ormq',
+    'add_processedmail',
+    'view_paperlesstask',
+    'delete_savedview',
+    'change_user',
+    'add_session',
+    'view_groupobjectpermission',
+    'add_user',
+    'add_correspondent',
+    'delete_taskresult',
+    'view_contenttype',
+    'view_storagepath',
+    'add_permission',
+    'change_userobjectpermission',
+    'delete_savedviewfilterrule',
+    'change_groupresult',
+    'add_chordcounter',
+    'view_log',
+    'view_comment',
+    'change_frontendsettings',
+  ],
+}
+
+// run common tests
+commonAbstractNameFilterPaperlessServiceTests(endpoint, GroupService)
+
+describe('Additional service tests for GroupService', () => {
+  it('should retain permissions on update', () => {
+    subscription = service.listAll().subscribe()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+    )
+    req.flush({
+      results: [group],
+    })
+    subscription.unsubscribe()
+
+    subscription = service.update(group).subscribe()
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${group.id}/`
+    )
+    expect(req.request.body.permissions).toHaveLength(group.permissions.length)
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(GroupService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+})
diff --git a/src-ui/src/app/services/rest/log.service.spec.ts b/src-ui/src/app/services/rest/log.service.spec.ts
new file mode 100644 (file)
index 0000000..234c94b
--- /dev/null
@@ -0,0 +1,47 @@
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { LogService } from './log.service'
+
+let httpTestingController: HttpTestingController
+let service: LogService
+let subscription: Subscription
+const endpoint = 'logs'
+
+describe('LogService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [LogService],
+      imports: [HttpClientTestingModule],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(LogService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+
+  it('should call correct api endpoint on logs list', () => {
+    subscription = service.list().subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('should call correct api endpoint on logs get', () => {
+    const id: string = 'mail'
+    subscription = service.get(id).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${id}/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+})
diff --git a/src-ui/src/app/services/rest/mail-account.service.spec.ts b/src-ui/src/app/services/rest/mail-account.service.spec.ts
new file mode 100644 (file)
index 0000000..86e09e2
--- /dev/null
@@ -0,0 +1,80 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+import { MailAccountService } from './mail-account.service'
+import { IMAPSecurity } from 'src/app/data/paperless-mail-account'
+
+let httpTestingController: HttpTestingController
+let service: MailAccountService
+let subscription: Subscription
+const endpoint = 'mail_accounts'
+const mail_accounts = [
+  {
+    name: 'Mail Account',
+    id: 1,
+    imap_server: 'imap.example.com',
+    imap_port: 443,
+    imap_security: IMAPSecurity.SSL,
+    username: 'user',
+    password: 'pass',
+    is_token: false,
+  },
+  {
+    name: 'Mail Account 2',
+    id: 2,
+    imap_server: 'imap.example.com',
+    imap_port: 443,
+    imap_security: IMAPSecurity.SSL,
+    username: 'user',
+    password: 'pass',
+    is_token: false,
+  },
+  {
+    name: 'Mail Account 3',
+    id: 3,
+    imap_server: 'imap.example.com',
+    imap_port: 443,
+    imap_security: IMAPSecurity.SSL,
+    username: 'user',
+    password: 'pass',
+    is_token: false,
+  },
+]
+
+// run common tests
+commonAbstractPaperlessServiceTests(endpoint, MailAccountService)
+
+describe(`Additional service tests for MailAccountService`, () => {
+  it('should correct api endpoint on test account', () => {
+    subscription = service.test(mail_accounts[0]).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/test/`
+    )
+    expect(req.request.method).toEqual('POST')
+  })
+
+  it('should support patchMany', () => {
+    subscription = service.patchMany(mail_accounts).subscribe()
+    mail_accounts.forEach((mail_account) => {
+      const reqs = httpTestingController.match(
+        `${environment.apiBaseUrl}${endpoint}/${mail_account.id}/`
+      )
+      expect(reqs).toHaveLength(1)
+      expect(reqs[0].request.method).toEqual('PATCH')
+    })
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(MailAccountService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+})
diff --git a/src-ui/src/app/services/rest/mail-rule.service.spec.ts b/src-ui/src/app/services/rest/mail-rule.service.spec.ts
new file mode 100644 (file)
index 0000000..daff573
--- /dev/null
@@ -0,0 +1,92 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+import { MailRuleService } from './mail-rule.service'
+import { MailFilterAttachmentType } from 'src/app/data/paperless-mail-rule'
+import { MailMetadataTitleOption } from 'src/app/data/paperless-mail-rule'
+import { MailAction } from 'src/app/data/paperless-mail-rule'
+
+let httpTestingController: HttpTestingController
+let service: MailRuleService
+let subscription: Subscription
+const endpoint = 'mail_rules'
+const mail_rules = [
+  {
+    name: 'Mail Rule',
+    id: 1,
+    account: 1,
+    order: 1,
+    folder: 'INBOX',
+    filter_from: null,
+    filter_to: null,
+    filter_subject: null,
+    filter_body: null,
+    filter_attachment_filename: null,
+    maximum_age: 30,
+    attachment_type: MailFilterAttachmentType.Everything,
+    action: MailAction.MarkRead,
+    assign_title_from: MailMetadataTitleOption.FromSubject,
+  },
+  {
+    name: 'Mail Rule 2',
+    id: 2,
+    account: 1,
+    order: 1,
+    folder: 'INBOX',
+    filter_from: null,
+    filter_to: null,
+    filter_subject: null,
+    filter_body: null,
+    filter_attachment_filename: null,
+    maximum_age: 30,
+    attachment_type: MailFilterAttachmentType.Everything,
+    action: MailAction.Delete,
+    assign_title_from: MailMetadataTitleOption.FromSubject,
+  },
+  {
+    name: 'Mail Rule 3',
+    id: 3,
+    account: 1,
+    order: 1,
+    folder: 'INBOX',
+    filter_from: null,
+    filter_to: null,
+    filter_subject: null,
+    filter_body: null,
+    filter_attachment_filename: null,
+    maximum_age: 30,
+    attachment_type: MailFilterAttachmentType.Everything,
+    action: MailAction.Flag,
+    assign_title_from: MailMetadataTitleOption.FromSubject,
+  },
+]
+
+// run common tests
+commonAbstractPaperlessServiceTests(endpoint, MailRuleService)
+
+describe(`Additional service tests for MailRuleService`, () => {
+  it('should support patchMany', () => {
+    subscription = service.patchMany(mail_rules).subscribe()
+    mail_rules.forEach((mail_rule) => {
+      const reqs = httpTestingController.match(
+        `${environment.apiBaseUrl}${endpoint}/${mail_rule.id}/`
+      )
+      expect(reqs).toHaveLength(1)
+      expect(reqs[0].request.method).toEqual('PATCH')
+    })
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(MailRuleService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+})
diff --git a/src-ui/src/app/services/rest/remote-version.service.spec.ts b/src-ui/src/app/services/rest/remote-version.service.spec.ts
new file mode 100644 (file)
index 0000000..f816e2d
--- /dev/null
@@ -0,0 +1,38 @@
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { RemoteVersionService } from './remote-version.service'
+
+let httpTestingController: HttpTestingController
+let service: RemoteVersionService
+let subscription: Subscription
+const endpoint = 'remote_version'
+
+describe('RemoteVersionService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [RemoteVersionService],
+      imports: [HttpClientTestingModule],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(RemoteVersionService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+
+  it('should call correct api endpoint on update check', () => {
+    subscription = service.checkForUpdates().subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+})
diff --git a/src-ui/src/app/services/rest/saved-view.service.spec.ts b/src-ui/src/app/services/rest/saved-view.service.spec.ts
new file mode 100644 (file)
index 0000000..1e0c761
--- /dev/null
@@ -0,0 +1,81 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+import { SavedViewService } from './saved-view.service'
+
+let httpTestingController: HttpTestingController
+let service: SavedViewService
+let subscription: Subscription
+const endpoint = 'saved_views'
+const saved_views = [
+  {
+    name: 'Saved View',
+    id: 1,
+    show_on_dashboard: true,
+    show_in_sidebar: true,
+    sort_field: 'name',
+    sort_reverse: true,
+    filter_rules: [],
+  },
+  {
+    name: 'Saved View 2',
+    id: 2,
+    show_on_dashboard: false,
+    show_in_sidebar: false,
+    sort_field: 'name',
+    sort_reverse: true,
+    filter_rules: [],
+  },
+  {
+    name: 'Saved View 3',
+    id: 3,
+    show_on_dashboard: true,
+    show_in_sidebar: false,
+    sort_field: 'name',
+    sort_reverse: true,
+    filter_rules: [],
+  },
+]
+
+// run common tests
+commonAbstractPaperlessServiceTests(endpoint, SavedViewService)
+
+describe(`Additional service tests for SavedViewService`, () => {
+  it('should retrieve saved views and sort them', () => {
+    service.initialize()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+    )
+    req.flush({
+      results: saved_views,
+    })
+    expect(service.allViews).toHaveLength(3)
+    expect(service.dashboardViews).toHaveLength(2)
+    expect(service.sidebarViews).toHaveLength(1)
+  })
+
+  it('should support patchMany', () => {
+    subscription = service.patchMany(saved_views).subscribe()
+    saved_views.forEach((saved_view) => {
+      const reqs = httpTestingController.match(
+        `${environment.apiBaseUrl}${endpoint}/${saved_view.id}/`
+      )
+      expect(reqs).toHaveLength(1)
+      expect(reqs[0].request.method).toEqual('PATCH')
+    })
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(SavedViewService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+})
diff --git a/src-ui/src/app/services/rest/search.service.spec.ts b/src-ui/src/app/services/rest/search.service.spec.ts
new file mode 100644 (file)
index 0000000..7f42aa7
--- /dev/null
@@ -0,0 +1,39 @@
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { SearchService } from './search.service'
+
+let httpTestingController: HttpTestingController
+let service: SearchService
+let subscription: Subscription
+const endpoint = 'search/autocomplete'
+
+describe('SearchService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [SearchService],
+      imports: [HttpClientTestingModule],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(SearchService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+
+  it('should call correct api endpoint on autocomplete', () => {
+    const term = 'apple'
+    subscription = service.autocomplete(term).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?term=${term}`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+})
diff --git a/src-ui/src/app/services/rest/storage-path.service.spec.ts b/src-ui/src/app/services/rest/storage-path.service.spec.ts
new file mode 100644 (file)
index 0000000..f365f6a
--- /dev/null
@@ -0,0 +1,7 @@
+import { StoragePathService } from './storage-path.service'
+import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+
+commonAbstractNameFilterPaperlessServiceTests(
+  'storage_paths',
+  StoragePathService
+)
diff --git a/src-ui/src/app/services/rest/tag.service.spec.ts b/src-ui/src/app/services/rest/tag.service.spec.ts
new file mode 100644 (file)
index 0000000..9b28fc9
--- /dev/null
@@ -0,0 +1,4 @@
+import { TagService } from './tag.service'
+import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+
+commonAbstractNameFilterPaperlessServiceTests('tags', TagService)
diff --git a/src-ui/src/app/services/rest/user.service.spec.ts b/src-ui/src/app/services/rest/user.service.spec.ts
new file mode 100644 (file)
index 0000000..acf6634
--- /dev/null
@@ -0,0 +1,193 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+import { UserService } from './user.service'
+
+let httpTestingController: HttpTestingController
+let service: UserService
+let subscription: Subscription
+const endpoint = 'users'
+const user = {
+  username: 'username',
+  id: 1,
+  user_permissions: [
+    'change_savedview',
+    'change_schedule',
+    'change_failure',
+    'delete_token',
+    'add_mailrule',
+    'view_failure',
+    'view_groupresult',
+    'add_note',
+    'change_taskresult',
+    'view_tag',
+    'view_user',
+    'add_tag',
+    'change_processedmail',
+    'change_session',
+    'view_taskattributes',
+    'delete_groupresult',
+    'delete_correspondent',
+    'delete_schedule',
+    'delete_contenttype',
+    'view_chordcounter',
+    'view_success',
+    'delete_documenttype',
+    'add_tokenproxy',
+    'delete_paperlesstask',
+    'add_log',
+    'view_mailaccount',
+    'add_uisettings',
+    'view_savedview',
+    'view_uisettings',
+    'delete_storagepath',
+    'delete_frontendsettings',
+    'change_paperlesstask',
+    'view_taskresult',
+    'delete_processedmail',
+    'view_processedmail',
+    'view_session',
+    'delete_chordcounter',
+    'view_note',
+    'delete_session',
+    'view_document',
+    'change_mailaccount',
+    'delete_taskattributes',
+    'add_groupobjectpermission',
+    'view_mailrule',
+    'change_savedviewfilterrule',
+    'change_log',
+    'change_comment',
+    'add_mailaccount',
+    'add_frontendsettings',
+    'add_userobjectpermission',
+    'delete_note',
+    'view_token',
+    'add_failure',
+    'delete_user',
+    'add_success',
+    'view_ormq',
+    'view_tokenproxy',
+    'delete_uisettings',
+    'change_groupobjectpermission',
+    'add_logentry',
+    'add_ormq',
+    'view_frontendsettings',
+    'view_schedule',
+    'change_taskattributes',
+    'view_documenttype',
+    'view_logentry',
+    'change_correspondent',
+    'add_groupresult',
+    'delete_groupobjectpermission',
+    'change_mailrule',
+    'change_permission',
+    'delete_log',
+    'view_userobjectpermission',
+    'view_correspondent',
+    'delete_document',
+    'change_uisettings',
+    'change_storagepath',
+    'change_document',
+    'delete_tokenproxy',
+    'change_note',
+    'delete_permission',
+    'change_contenttype',
+    'add_token',
+    'change_success',
+    'delete_logentry',
+    'view_savedviewfilterrule',
+    'delete_task',
+    'add_savedview',
+    'add_paperlesstask',
+    'add_task',
+    'change_documenttype',
+    'add_documenttype',
+    'change_token',
+    'view_task',
+    'view_permission',
+    'change_task',
+    'delete_userobjectpermission',
+    'change_group',
+    'add_group',
+    'change_tag',
+    'change_chordcounter',
+    'add_storagepath',
+    'delete_group',
+    'add_taskattributes',
+    'delete_mailaccount',
+    'delete_tag',
+    'add_schedule',
+    'delete_failure',
+    'delete_mailrule',
+    'add_savedviewfilterrule',
+    'change_ormq',
+    'change_logentry',
+    'add_taskresult',
+    'view_group',
+    'delete_comment',
+    'add_contenttype',
+    'add_document',
+    'change_tokenproxy',
+    'delete_success',
+    'add_comment',
+    'delete_ormq',
+    'add_processedmail',
+    'view_paperlesstask',
+    'delete_savedview',
+    'change_user',
+    'add_session',
+    'view_groupobjectpermission',
+    'add_user',
+    'add_correspondent',
+    'delete_taskresult',
+    'view_contenttype',
+    'view_storagepath',
+    'add_permission',
+    'change_userobjectpermission',
+    'delete_savedviewfilterrule',
+    'change_groupresult',
+    'add_chordcounter',
+    'view_log',
+    'view_comment',
+    'change_frontendsettings',
+  ],
+}
+
+// run common tests
+commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService)
+
+describe('Additional service tests for UserService', () => {
+  it('should retain permissions on update', () => {
+    subscription = service.listAll().subscribe()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+    )
+    req.flush({
+      results: [user],
+    })
+    subscription.unsubscribe()
+
+    subscription = service.update(user).subscribe()
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${user.id}/`
+    )
+    expect(req.request.body.user_permissions).toHaveLength(
+      user.user_permissions.length
+    )
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(UserService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+})
diff --git a/src-ui/src/app/services/settings.service.spec.ts b/src-ui/src/app/services/settings.service.spec.ts
new file mode 100644 (file)
index 0000000..560b67a
--- /dev/null
@@ -0,0 +1,212 @@
+import { TestBed } from '@angular/core/testing'
+import { SettingsService } from './settings.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { RouterTestingModule } from '@angular/router/testing'
+import { environment } from 'src/environments/environment'
+import { Subscription } from 'rxjs'
+import { PaperlessUiSettings } from '../data/paperless-uisettings'
+import { SETTINGS_KEYS } from '../data/paperless-uisettings'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { AppModule } from '../app.module'
+
+describe('SettingsService', () => {
+  let httpTestingController: HttpTestingController
+  let settingsService: SettingsService
+  let subscription: Subscription
+
+  const ui_settings: PaperlessUiSettings = {
+    user: {
+      username: 'testuser',
+      first_name: 'Test',
+      last_name: 'User',
+      id: 1,
+      is_superuser: true,
+    },
+    settings: {
+      language: '',
+      bulk_edit: { confirmation_dialogs: true, apply_on_close: false },
+      documentListSize: 50,
+      dark_mode: { use_system: true, enabled: 'false', thumb_inverted: 'true' },
+      theme: { color: '#9fbf2f' },
+      document_details: { native_pdf_viewer: false },
+      date_display: { date_locale: '', date_format: 'mediumDate' },
+      notifications: {
+        consumer_new_documents: true,
+        consumer_success: true,
+        consumer_failed: true,
+        consumer_suppress_on_dashboard: true,
+      },
+      comments_enabled: true,
+      slim_sidebar: false,
+      update_checking: { enabled: false, backend_setting: 'default' },
+      saved_views: { warn_on_unsaved_change: true },
+      notes_enabled: true,
+      tour_complete: false,
+    },
+    permissions: [],
+  }
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [],
+      providers: [SettingsService],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule,
+        NgbModule,
+        FormsModule,
+        ReactiveFormsModule,
+        AppModule,
+      ],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    settingsService = TestBed.inject(SettingsService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+
+  it('calls ui_settings api endpoint on initialize', () => {
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('calls ui_settings api endpoint with POST on store', () => {
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    req.flush(ui_settings)
+
+    subscription = settingsService.storeSettings().subscribe()
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    expect(req.request.method).toEqual('POST')
+    expect(req.request.body).toEqual({
+      settings: ui_settings.settings,
+    })
+  })
+
+  it('correctly loads settings of various types', () => {
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    req.flush(ui_settings)
+
+    expect(settingsService.displayName).toEqual('Test')
+    expect(settingsService.getLanguage()).toEqual('')
+    expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeFalsy()
+    expect(
+      settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
+    ).toBeTruthy()
+    expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(50)
+    expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
+  })
+
+  it('correctly allows updating settings of various types', () => {
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    req.flush(ui_settings)
+
+    settingsService.setLanguage('de-de')
+    settingsService.set(SETTINGS_KEYS.DARK_MODE_ENABLED, true)
+    settingsService.set(
+      SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
+      false
+    )
+    settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 25)
+    settingsService.set(SETTINGS_KEYS.THEME_COLOR, '#000000')
+
+    expect(settingsService.getLanguage()).toEqual('de-de')
+    expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeTruthy()
+    expect(
+      settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
+    ).toBeFalsy()
+    expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(25)
+    expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
+  })
+
+  it('updates appearnce settings', () => {
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    req.flush(ui_settings)
+
+    expect(
+      document.body.style.getPropertyValue('--pngx-primary-lightness')
+    ).toEqual('')
+
+    const addClassSpy = jest.spyOn(settingsService.renderer, 'addClass')
+    const removeClassSpy = jest.spyOn(settingsService.renderer, 'removeClass')
+
+    settingsService.updateAppearanceSettings(true, true, '#fff000')
+    expect(addClassSpy).toHaveBeenCalledWith(document.body, 'primary-light')
+    expect(addClassSpy).toHaveBeenCalledWith(
+      document.body,
+      'color-scheme-system'
+    )
+    expect(
+      document.body.style.getPropertyValue('--pngx-primary-lightness')
+    ).toEqual('50%')
+
+    settingsService.updateAppearanceSettings(false, false, '#000000')
+    expect(addClassSpy).toHaveBeenCalledWith(document.body, 'primary-light')
+    expect(removeClassSpy).toHaveBeenCalledWith(
+      document.body,
+      'color-scheme-system'
+    )
+    expect(
+      document.body.style.getPropertyValue('--pngx-primary-lightness')
+    ).toEqual('0%')
+
+    settingsService.updateAppearanceSettings(false, true, '#ffffff')
+    expect(addClassSpy).toHaveBeenCalledWith(document.body, 'primary-dark')
+    expect(removeClassSpy).toHaveBeenCalledWith(
+      document.body,
+      'color-scheme-system'
+    )
+    expect(addClassSpy).toHaveBeenCalledWith(document.body, 'color-scheme-dark')
+    expect(
+      document.body.style.getPropertyValue('--pngx-primary-lightness')
+    ).toEqual('100%')
+  })
+
+  it('migrates settings automatically', () => {
+    const oldSettings = Object.assign({}, ui_settings)
+    delete oldSettings.settings['documentListSize']
+    window.localStorage.setItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, '50')
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    req.flush(oldSettings)
+
+    req = httpTestingController.match(
+      `${environment.apiBaseUrl}ui_settings/`
+    )[0]
+    expect(req.request.method).toEqual('POST')
+  })
+
+  it('updates settings on complete tour', () => {
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}ui_settings/`
+    )
+    req.flush(ui_settings)
+
+    settingsService.completeTour()
+
+    req = httpTestingController.match(
+      `${environment.apiBaseUrl}ui_settings/`
+    )[0]
+    expect(req.request.method).toEqual('POST')
+  })
+})
index 207ccba565fc644bc61de6066748991e33b6913c..f47c6b7e953c298245aba6e52a6976e72f12f313 100644 (file)
@@ -43,7 +43,6 @@ export interface LanguageOption {
   providedIn: 'root',
 })
 export class SettingsService {
-  private renderer: Renderer2
   protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/'
 
   private settings: Object = {}
@@ -51,6 +50,11 @@ export class SettingsService {
 
   public settingsSaved: EventEmitter<any> = new EventEmitter()
 
+  private _renderer: Renderer2
+  public get renderer(): Renderer2 {
+    return this._renderer
+  }
+
   constructor(
     rendererFactory: RendererFactory2,
     @Inject(DOCUMENT) private document,
@@ -62,7 +66,7 @@ export class SettingsService {
     private savedViewService: SavedViewService,
     private permissionsService: PermissionsService
   ) {
-    this.renderer = rendererFactory.createRenderer(null, null)
+    this._renderer = rendererFactory.createRenderer(null, null)
   }
 
   // this is called by the app initializer in app.module
@@ -102,49 +106,49 @@ export class SettingsService {
     themeColor ??= this.get(SETTINGS_KEYS.THEME_COLOR)
 
     if (darkModeUseSystem) {
-      this.renderer.addClass(this.document.body, 'color-scheme-system')
-      this.renderer.removeClass(this.document.body, 'color-scheme-dark')
+      this._renderer.addClass(this.document.body, 'color-scheme-system')
+      this._renderer.removeClass(this.document.body, 'color-scheme-dark')
     } else {
-      this.renderer.removeClass(this.document.body, 'color-scheme-system')
+      this._renderer.removeClass(this.document.body, 'color-scheme-system')
       darkModeEnabled
-        ? this.renderer.addClass(this.document.body, 'color-scheme-dark')
-        : this.renderer.removeClass(this.document.body, 'color-scheme-dark')
+        ? this._renderer.addClass(this.document.body, 'color-scheme-dark')
+        : this._renderer.removeClass(this.document.body, 'color-scheme-dark')
     }
 
     // remove these in case they were there
-    this.renderer.removeClass(this.document.body, 'primary-dark')
-    this.renderer.removeClass(this.document.body, 'primary-light')
+    this._renderer.removeClass(this.document.body, 'primary-dark')
+    this._renderer.removeClass(this.document.body, 'primary-light')
 
     if (themeColor) {
       const hsl = hexToHsl(themeColor)
       const bgBrightnessEstimate = estimateBrightnessForColor(themeColor)
 
       if (bgBrightnessEstimate == BRIGHTNESS.DARK) {
-        this.renderer.addClass(this.document.body, 'primary-dark')
-        this.renderer.removeClass(this.document.body, 'primary-light')
+        this._renderer.addClass(this.document.body, 'primary-dark')
+        this._renderer.removeClass(this.document.body, 'primary-light')
       } else {
-        this.renderer.addClass(this.document.body, 'primary-light')
-        this.renderer.removeClass(this.document.body, 'primary-dark')
+        this._renderer.addClass(this.document.body, 'primary-light')
+        this._renderer.removeClass(this.document.body, 'primary-dark')
       }
-      this.renderer.setStyle(
+      this._renderer.setStyle(
         document.body,
         '--pngx-primary',
         `${+hsl.h * 360},${hsl.s * 100}%`,
         RendererStyleFlags2.DashCase
       )
-      this.renderer.setStyle(
+      this._renderer.setStyle(
         document.body,
         '--pngx-primary-lightness',
         `${hsl.l * 100}%`,
         RendererStyleFlags2.DashCase
       )
     } else {
-      this.renderer.removeStyle(
+      this._renderer.removeStyle(
         document.body,
         '--pngx-primary',
         RendererStyleFlags2.DashCase
       )
-      this.renderer.removeStyle(
+      this._renderer.removeStyle(
         document.body,
         '--pngx-primary-lightness',
         RendererStyleFlags2.DashCase
diff --git a/src-ui/src/app/services/tasks.service.spec.ts b/src-ui/src/app/services/tasks.service.spec.ts
new file mode 100644 (file)
index 0000000..c41b69f
--- /dev/null
@@ -0,0 +1,110 @@
+import { TestBed } from '@angular/core/testing'
+import { TasksService } from './tasks.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { environment } from 'src/environments/environment'
+import { PaperlessTaskType } from '../data/paperless-task'
+import { PaperlessTaskStatus } from '../data/paperless-task'
+
+describe('TasksService', () => {
+  let httpTestingController: HttpTestingController
+  let tasksService: TasksService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [TasksService],
+      imports: [HttpClientTestingModule],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    tasksService = TestBed.inject(TasksService)
+  })
+
+  afterEach(() => {
+    httpTestingController.verify()
+  })
+
+  it('calls tasks api endpoint on reload', () => {
+    tasksService.reload()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}tasks/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
+    tasksService.dismissTasks(new Set([1, 2, 3]))
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}acknowledge_tasks/`
+    )
+    expect(req.request.method).toEqual('POST')
+    expect(req.request.body).toEqual({
+      tasks: [1, 2, 3],
+    })
+    req.flush([])
+    // reload is then called
+    httpTestingController.expectOne(`${environment.apiBaseUrl}tasks/`).flush([])
+  })
+
+  it('sorts tasks returned from api', () => {
+    expect(tasksService.total).toEqual(0)
+    const mockTasks = [
+      {
+        type: PaperlessTaskType.File,
+        status: PaperlessTaskStatus.Complete,
+        acknowledged: false,
+        task_id: '1234',
+        task_file_name: 'file1.pdf',
+        date_created: new Date(),
+      },
+      {
+        type: PaperlessTaskType.File,
+        status: PaperlessTaskStatus.Failed,
+        acknowledged: false,
+        task_id: '1235',
+        task_file_name: 'file2.pdf',
+        date_created: new Date(),
+      },
+      {
+        type: PaperlessTaskType.File,
+        status: PaperlessTaskStatus.Pending,
+        acknowledged: false,
+        task_id: '1236',
+        task_file_name: 'file3.pdf',
+        date_created: new Date(),
+      },
+      {
+        type: PaperlessTaskType.File,
+        status: PaperlessTaskStatus.Started,
+        acknowledged: false,
+        task_id: '1237',
+        task_file_name: 'file4.pdf',
+        date_created: new Date(),
+      },
+      {
+        type: PaperlessTaskType.File,
+        status: PaperlessTaskStatus.Complete,
+        acknowledged: false,
+        task_id: '1238',
+        task_file_name: 'file5.pdf',
+        date_created: new Date(),
+      },
+    ]
+
+    tasksService.reload()
+
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}tasks/`
+    )
+
+    req.flush(mockTasks)
+
+    expect(tasksService.allFileTasks).toHaveLength(5)
+    expect(tasksService.completedFileTasks).toHaveLength(2)
+    expect(tasksService.failedFileTasks).toHaveLength(1)
+    expect(tasksService.queuedFileTasks).toHaveLength(1)
+    expect(tasksService.startedFileTasks).toHaveLength(1)
+  })
+})
index 4607128a13582e8495df61b7989cd0b52bd7b919..9dfd118e76a3579c60d6761b5df799388888d876 100644 (file)
@@ -19,7 +19,7 @@ export class TasksService {
   private fileTasks: PaperlessTask[] = []
 
   public get total(): number {
-    return this.fileTasks?.length
+    return this.fileTasks.length
   }
 
   public get allFileTasks(): PaperlessTask[] {
diff --git a/src-ui/src/app/services/toast.service.spec.ts b/src-ui/src/app/services/toast.service.spec.ts
new file mode 100644 (file)
index 0000000..274ea9d
--- /dev/null
@@ -0,0 +1,57 @@
+import { TestBed } from '@angular/core/testing'
+import { ToastService } from './toast.service'
+
+describe('ToastService', () => {
+  let toastService: ToastService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [ToastService],
+    })
+
+    toastService = TestBed.inject(ToastService)
+  })
+
+  it('adds toast on show', () => {
+    const toast = {
+      title: 'Title',
+      content: 'content',
+      delay: 5000,
+    }
+    toastService.show(toast)
+
+    toastService.getToasts().subscribe((toasts) => {
+      expect(toasts).toContainEqual(toast)
+    })
+  })
+
+  it('creates toasts with defaults on showInfo and showError', () => {
+    toastService.showInfo('Info toast')
+    toastService.showError('Error toast')
+
+    toastService.getToasts().subscribe((toasts) => {
+      expect(toasts).toContainEqual({
+        content: 'Info toast',
+        delay: 5000,
+      })
+      expect(toasts).toContainEqual({
+        content: 'Error toast',
+        delay: 10000,
+      })
+    })
+  })
+
+  it('removes toast on close', () => {
+    const toast = {
+      title: 'Title',
+      content: 'content',
+      delay: 5000,
+    }
+    toastService.show(toast)
+    toastService.closeToast(toast)
+
+    toastService.getToasts().subscribe((toasts) => {
+      expect(toasts).toHaveLength(0)
+    })
+  })
+})
diff --git a/src-ui/src/app/services/upload-documents.service.spec.ts b/src-ui/src/app/services/upload-documents.service.spec.ts
new file mode 100644 (file)
index 0000000..0c3ab80
--- /dev/null
@@ -0,0 +1,169 @@
+import { TestBed } from '@angular/core/testing'
+import { UploadDocumentsService } from './upload-documents.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { environment } from 'src/environments/environment'
+import { HttpEventType, HttpResponse } from '@angular/common/http'
+import {
+  ConsumerStatusService,
+  FileStatusPhase,
+} from './consumer-status.service'
+
+describe('UploadDocumentsService', () => {
+  let httpTestingController: HttpTestingController
+  let uploadDocumentsService: UploadDocumentsService
+  let consumerStatusService: ConsumerStatusService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [UploadDocumentsService, ConsumerStatusService],
+      imports: [HttpClientTestingModule],
+    })
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    uploadDocumentsService = TestBed.inject(UploadDocumentsService)
+    consumerStatusService = TestBed.inject(ConsumerStatusService)
+  })
+
+  afterEach(() => {
+    httpTestingController.verify()
+  })
+
+  it('calls post_document api endpoint on upload', () => {
+    const fileEntry = {
+      name: 'file.pdf',
+      isDirectory: false,
+      isFile: true,
+      file: (callback) => {
+        return callback(
+          new File(
+            [new Blob(['testing'], { type: 'application/pdf' })],
+            'file.pdf'
+          )
+        )
+      },
+    }
+    uploadDocumentsService.uploadFiles([
+      {
+        relativePath: 'path/to/file.pdf',
+        fileEntry,
+      },
+    ])
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/post_document/`
+    )
+    expect(req.request.method).toEqual('POST')
+
+    req.flush('123-456')
+  })
+
+  it('updates progress during upload and failure', () => {
+    const fileEntry = {
+      name: 'file.pdf',
+      isDirectory: false,
+      isFile: true,
+      file: (callback) => {
+        return callback(
+          new File(
+            [new Blob(['testing'], { type: 'application/pdf' })],
+            'file.pdf'
+          )
+        )
+      },
+    }
+    uploadDocumentsService.uploadFiles([
+      {
+        relativePath: 'path/to/file.pdf',
+        fileEntry,
+      },
+    ])
+
+    expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
+      1
+    )
+    expect(
+      consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
+    ).toHaveLength(0)
+
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/post_document/`
+    )
+
+    req.event({
+      type: HttpEventType.UploadProgress,
+      loaded: 100,
+      total: 300,
+    })
+
+    expect(
+      consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
+    ).toHaveLength(1)
+  })
+
+  it('updates progress on failure', () => {
+    const fileEntry = {
+      name: 'file.pdf',
+      isDirectory: false,
+      isFile: true,
+      file: (callback) => {
+        return callback(
+          new File(
+            [new Blob(['testing'], { type: 'application/pdf' })],
+            'file.pdf'
+          )
+        )
+      },
+    }
+    uploadDocumentsService.uploadFiles([
+      {
+        relativePath: 'path/to/file.pdf',
+        fileEntry,
+      },
+    ])
+
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/post_document/`
+    )
+
+    expect(
+      consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
+    ).toHaveLength(0)
+
+    req.flush(
+      {},
+      {
+        status: 400,
+        statusText: 'failed',
+      }
+    )
+
+    expect(
+      consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
+    ).toHaveLength(1)
+
+    uploadDocumentsService.uploadFiles([
+      {
+        relativePath: 'path/to/file.pdf',
+        fileEntry,
+      },
+    ])
+
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/post_document/`
+    )
+
+    req.flush(
+      {},
+      {
+        status: 500,
+        statusText: 'failed',
+      }
+    )
+
+    expect(
+      consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
+    ).toHaveLength(2)
+  })
+})
index f214a3cb51d2ab80ed4ffe81acb8609e4dbcab82..5e7ef7fbe2b2fa6189bd81c54717938ea265920a 100644 (file)
@@ -7,7 +7,6 @@ import {
 } from './consumer-status.service'
 import { DocumentService } from './rest/document.service'
 import { Subscription } from 'rxjs'
-import { SettingsService } from './settings.service'
 
 @Injectable({
   providedIn: 'root',
@@ -17,8 +16,7 @@ export class UploadDocumentsService {
 
   constructor(
     private documentService: DocumentService,
-    private consumerStatusService: ConsumerStatusService,
-    private settings: SettingsService
+    private consumerStatusService: ConsumerStatusService
   ) {}
 
   uploadFiles(files: NgxFileDropEntry[]) {
diff --git a/src-ui/src/app/utils/color.spec.ts b/src-ui/src/app/utils/color.spec.ts
new file mode 100644 (file)
index 0000000..4dddb59
--- /dev/null
@@ -0,0 +1,50 @@
+import {
+  BRIGHTNESS,
+  computeLuminance,
+  estimateBrightnessForColor,
+  hexToHsl,
+  randomColor,
+  rgbToHsl,
+} from './color'
+
+describe('Color Utils', () => {
+  it('should convert hex to hsl', () => {
+    let hsl = hexToHsl('#0000FF')
+    expect(hsl).toEqual({
+      h: 0.6666666666666666,
+      s: 1,
+      l: 0.5,
+    })
+  })
+
+  it('should compute luminance', () => {
+    let luminance = computeLuminance({ r: 0, g: 0, b: 0 })
+    expect(luminance).toEqual(0)
+    luminance = computeLuminance({ r: 255, g: 255, b: 255 })
+    expect(luminance).toEqual(1)
+    luminance = computeLuminance({ r: 128, g: 128, b: 128 })
+    expect(luminance).toBeCloseTo(0.22)
+  })
+
+  it('should estimate brightness', () => {
+    let brightness = estimateBrightnessForColor('#FFFF00') // yellow
+    expect(brightness).toEqual(BRIGHTNESS.LIGHT)
+    brightness = estimateBrightnessForColor('#800000') // maroon
+    expect(brightness).toEqual(BRIGHTNESS.DARK)
+  })
+
+  it('should convert rgb to hsl', () => {
+    let hsl = rgbToHsl(0, 255, 0)
+    expect(hsl).toEqual([0.3333333333333333, 1, 0.5])
+    hsl = rgbToHsl(255, 255, 0)
+    expect(hsl).toEqual([0.16666666666666666, 1, 0.5])
+    hsl = rgbToHsl(0, 0, 255)
+    expect(hsl).toEqual([0.6666666666666666, 1, 0.5])
+    hsl = rgbToHsl(128, 128, 128)
+    expect(hsl).toEqual([0, 0, 0.5019607843137255])
+  })
+
+  it('should return a random color', () => {
+    expect(randomColor()).not.toBeNull()
+  })
+})
diff --git a/src-ui/src/app/utils/filter-rules.spec.ts b/src-ui/src/app/utils/filter-rules.spec.ts
new file mode 100644 (file)
index 0000000..b022186
--- /dev/null
@@ -0,0 +1,48 @@
+import { cloneFilterRules } from './filter-rules'
+import { FilterRule } from '../data/filter-rule'
+import {
+  FILTER_FULLTEXT_QUERY,
+  FILTER_HAS_TAGS_ALL,
+} from '../data/filter-rule-type'
+import { isFullTextFilterRule } from './filter-rules'
+import { filterRulesDiffer } from './filter-rules'
+
+const filterRules: FilterRule[] = [
+  {
+    rule_type: FILTER_HAS_TAGS_ALL,
+    value: '9',
+  },
+]
+
+describe('FilterRules Utils', () => {
+  it('should clone filter rules', () => {
+    let rules = cloneFilterRules(filterRules)
+    expect(rules).toEqual(filterRules)
+
+    rules = cloneFilterRules(null)
+    expect(rules).toBeNull()
+  })
+
+  it('should determine if filter rule is a full text rule', () => {
+    const rules = [
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'hello',
+      },
+    ]
+    expect(isFullTextFilterRule(rules)).toBeTruthy()
+    expect(isFullTextFilterRule(filterRules)).toBeFalsy()
+  })
+
+  it('should determine if filter rule sets differ', () => {
+    const rules2 = [
+      {
+        rule_type: FILTER_FULLTEXT_QUERY,
+        value: 'hello',
+      },
+    ]
+    expect(filterRulesDiffer(filterRules, [])).toBeTruthy()
+    expect(filterRulesDiffer(filterRules, rules2)).toBeTruthy()
+    expect(filterRulesDiffer(filterRules, filterRules)).toBeFalsy()
+  })
+})
diff --git a/src-ui/src/app/utils/filter-rules.ts b/src-ui/src/app/utils/filter-rules.ts
new file mode 100644 (file)
index 0000000..14e57a6
--- /dev/null
@@ -0,0 +1,46 @@
+import { FilterRule } from '../data/filter-rule'
+import {
+  FILTER_FULLTEXT_MORELIKE,
+  FILTER_FULLTEXT_QUERY,
+} from '../data/filter-rule-type'
+
+export function cloneFilterRules(filterRules: FilterRule[]): FilterRule[] {
+  if (filterRules) {
+    let newRules: FilterRule[] = []
+    for (let rule of filterRules) {
+      newRules.push({ rule_type: rule.rule_type, value: rule.value })
+    }
+    return newRules
+  } else {
+    return null
+  }
+}
+
+export function isFullTextFilterRule(filterRules: FilterRule[]): boolean {
+  return (
+    filterRules.find(
+      (r) =>
+        r.rule_type == FILTER_FULLTEXT_QUERY ||
+        r.rule_type == FILTER_FULLTEXT_MORELIKE
+    ) != null
+  )
+}
+
+export function filterRulesDiffer(
+  filterRulesA: FilterRule[],
+  filterRulesB: FilterRule[]
+): boolean {
+  let differ = false
+  if (filterRulesA.length != filterRulesB.length) {
+    differ = true
+  } else {
+    differ = filterRulesA.some((rule) => {
+      return (
+        filterRulesB.find(
+          (fri) => fri.rule_type == rule.rule_type && fri.value == rule.value
+        ) == undefined
+      )
+    })
+  }
+  return differ
+}
diff --git a/src-ui/src/app/utils/ngb-date-parser-formatter.spec.ts b/src-ui/src/app/utils/ngb-date-parser-formatter.spec.ts
new file mode 100644 (file)
index 0000000..b8531bb
--- /dev/null
@@ -0,0 +1,67 @@
+import { TestBed } from '@angular/core/testing'
+import { LocalizedDateParserFormatter } from './ngb-date-parser-formatter'
+import { SettingsService } from '../services/settings.service'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+
+describe('LocalizedDateParserFormatter', () => {
+  let dateParserFormatter: LocalizedDateParserFormatter
+  let settingsService: SettingsService
+  let httpTestingController: HttpTestingController
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [LocalizedDateParserFormatter, SettingsService],
+      imports: [HttpClientTestingModule],
+    })
+
+    dateParserFormatter = TestBed.inject(LocalizedDateParserFormatter)
+    settingsService = TestBed.inject(SettingsService)
+    httpTestingController = TestBed.inject(HttpTestingController)
+  })
+
+  it('should parse date to struct by locale', () => {
+    let val = dateParserFormatter.parse('5/4/2023')
+    expect(val).toEqual({ day: 4, month: 5, year: 2023 })
+    val = dateParserFormatter.parse('5/4/23')
+    expect(val.day).toEqual(4)
+    expect(val.month).toEqual(5)
+    expect(val.year).toEqual(2023)
+    val = dateParserFormatter.parse('05042023')
+    expect(val.day).toEqual(4)
+    expect(val.month).toEqual(5)
+    expect(val.year).toEqual(2023)
+    val = dateParserFormatter.parse('12/13')
+    expect(val.day).toEqual(13)
+    expect(val.month).toEqual(12)
+    expect(val.year).toEqual(new Date().getFullYear())
+
+    settingsService.setLanguage('de-de') // dd.mm.yyyy
+    val = dateParserFormatter.parse('04.05.2023')
+    expect(val).toEqual({ day: 4, month: 5, year: 2023 })
+    val = dateParserFormatter.parse('04052023')
+    expect(val).toEqual({ day: 4, month: 5, year: 2023 })
+
+    settingsService.setLanguage('tr-tr') // yyyy-mm-dd
+    val = dateParserFormatter.parse('2023-05-04')
+    expect(val).toEqual({ day: 4, month: 5, year: 2023 })
+    val = dateParserFormatter.parse('20230504')
+    expect(val).toEqual({ day: 4, month: 5, year: 2023 })
+  })
+
+  it('should parse date struct to string by locale', () => {
+    const dateStruct = {
+      day: 4,
+      month: 5,
+      year: 2023,
+    }
+    let dateStr = dateParserFormatter.format(dateStruct)
+    expect(dateStr).toEqual('05/04/2023')
+
+    settingsService.setLanguage('de-de') // dd.mm.yyyy
+    dateStr = dateParserFormatter.format(dateStruct)
+    expect(dateStr).toEqual('04.05.2023')
+  })
+})
diff --git a/src-ui/src/app/utils/ngb-iso-date-adapter.spec.ts b/src-ui/src/app/utils/ngb-iso-date-adapter.spec.ts
new file mode 100644 (file)
index 0000000..6ed18f8
--- /dev/null
@@ -0,0 +1,40 @@
+import { TestBed } from '@angular/core/testing'
+import { ISODateAdapter } from './ngb-iso-date-adapter'
+
+describe('ISODateAdapter', () => {
+  let isoDateAdapter: ISODateAdapter
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [ISODateAdapter],
+    })
+    isoDateAdapter = TestBed.inject(ISODateAdapter)
+  })
+
+  it('should parse ISO date to struct', () => {
+    let val = isoDateAdapter.fromModel('2023-05-04')
+    expect(val.day).toEqual(4)
+    expect(val.month).toEqual(5)
+    expect(val.year).toEqual(2023)
+
+    val = isoDateAdapter.fromModel(null)
+    expect(val).toBeNull()
+
+    val = isoDateAdapter.fromModel('5/4/23')
+    expect(val.day).toEqual(4)
+    expect(val.month).toEqual(5)
+    expect(val.year).toEqual(2023)
+  })
+
+  it('should parse struct to ISO date', () => {
+    let val = isoDateAdapter.toModel({
+      day: 4,
+      month: 5,
+      year: 2023,
+    })
+    expect(val).toEqual('2023-05-04')
+
+    val = isoDateAdapter.toModel(null)
+    expect(val).toBeNull()
+  })
+})
diff --git a/src-ui/src/app/utils/query-params.spec.ts b/src-ui/src/app/utils/query-params.spec.ts
new file mode 100644 (file)
index 0000000..a1bc0cd
--- /dev/null
@@ -0,0 +1,196 @@
+import { convertToParamMap } from '@angular/router'
+import { FilterRule } from '../data/filter-rule'
+import {
+  FILTER_CORRESPONDENT,
+  FILTER_HAS_ANY_TAG,
+  FILTER_HAS_TAGS_ALL,
+} from '../data/filter-rule-type'
+import { paramsToViewState } from './query-params'
+import { paramsFromViewState } from './query-params'
+import { queryParamsFromFilterRules } from './query-params'
+import { filterRulesFromQueryParams } from './query-params'
+
+const tags__id__all = '9'
+const filterRules: FilterRule[] = [
+  {
+    rule_type: FILTER_HAS_TAGS_ALL,
+    value: tags__id__all,
+  },
+]
+
+describe('QueryParams Utils', () => {
+  it('should convert view state to params', () => {
+    let params = paramsFromViewState({
+      sortField: 'added',
+      sortReverse: true,
+      currentPage: 2,
+      filterRules,
+    })
+    expect(params).toEqual({
+      sort: 'added',
+      reverse: 1,
+      page: 2,
+      tags__id__all,
+    })
+
+    params = paramsFromViewState({
+      sortField: 'created',
+      sortReverse: false,
+      currentPage: NaN,
+      filterRules: [],
+    })
+    expect(params).toEqual({
+      sort: 'created',
+      reverse: undefined,
+      page: 1,
+    })
+
+    params = paramsFromViewState(
+      {
+        sortField: 'created',
+        sortReverse: false,
+        currentPage: 1,
+        filterRules: [],
+      },
+      true
+    )
+    expect(params).toEqual({
+      page: undefined,
+    })
+  })
+
+  it('should convert params to view state', () => {
+    const params = {
+      sort: 'created',
+      reverse: 1,
+      page: 1,
+    }
+    const state = paramsToViewState(convertToParamMap(params))
+    expect(state).toMatchObject({
+      currentPage: 1,
+      sortField: 'created',
+      sortReverse: true,
+      filterRules: [],
+    })
+  })
+
+  it('should convert params to filter rules', () => {
+    let params = queryParamsFromFilterRules(filterRules)
+    expect(params).toEqual({
+      tags__id__all,
+    })
+
+    params = queryParamsFromFilterRules([
+      {
+        rule_type: FILTER_CORRESPONDENT,
+        value: null,
+      },
+    ])
+    expect(params).toEqual({
+      correspondent__isnull: 1,
+    })
+
+    params = queryParamsFromFilterRules([
+      {
+        rule_type: FILTER_HAS_ANY_TAG,
+        value: 'true',
+      },
+    ])
+    expect(params).toEqual({
+      is_tagged: 1,
+    })
+
+    params = queryParamsFromFilterRules([
+      {
+        rule_type: FILTER_HAS_ANY_TAG,
+        value: 'false',
+      },
+    ])
+    expect(params).toEqual({
+      is_tagged: 0,
+    })
+
+    params = queryParamsFromFilterRules([
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: tags__id__all,
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: '14',
+      },
+    ])
+    expect(params).toEqual({
+      tags__id__all: tags__id__all + ',14',
+    })
+
+    params = queryParamsFromFilterRules(null)
+    expect(params).toBeNull()
+  })
+
+  it('should convert filter rules to query params', () => {
+    let rules = filterRulesFromQueryParams(
+      convertToParamMap({
+        tags__id__all,
+      })
+    )
+    expect(rules).toEqual([
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: tags__id__all,
+      },
+    ])
+
+    rules = filterRulesFromQueryParams(
+      convertToParamMap({
+        tags__id__all: tags__id__all + ',13',
+      })
+    )
+    expect(rules).toEqual([
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: tags__id__all,
+      },
+      {
+        rule_type: FILTER_HAS_TAGS_ALL,
+        value: '13',
+      },
+    ])
+
+    rules = filterRulesFromQueryParams(
+      convertToParamMap({
+        correspondent__id: '12',
+      })
+    )
+    expect(rules).toEqual([
+      {
+        rule_type: FILTER_CORRESPONDENT,
+        value: '12',
+      },
+    ])
+
+    rules = filterRulesFromQueryParams(
+      convertToParamMap({
+        is_tagged: 'true',
+      })
+    )
+    expect(rules).toEqual([
+      {
+        rule_type: FILTER_HAS_ANY_TAG,
+        value: 'true',
+      },
+    ])
+
+    rules = filterRulesFromQueryParams(
+      convertToParamMap({
+        correspondent__isnull: '1',
+      })
+    )
+    expect(rules).toEqual([
+      {
+        rule_type: FILTER_CORRESPONDENT,
+        value: null,
+      },
+    ])
+  })
+})
index b98801f7b89f1a14995a2333765466a331eeaaef..1121bd6a396b94207344ef05dfed85063932a822 100644 (file)
@@ -18,7 +18,7 @@ export function paramsFromViewState(
   params[PAGE_PARAMETER] = isNaN(viewState.currentPage)
     ? 1
     : viewState.currentPage
-  if (pageOnly && viewState.currentPage == 1) params[PAGE_PARAMETER] = null
+  if (pageOnly && viewState.currentPage == 1) params[PAGE_PARAMETER] = undefined
   return params
 }