From 06def8c11efb419b682dc42fc5da3f2cf1404b95 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 23 May 2023 15:02:54 -0700 Subject: [PATCH 01/12] frontend unit tests 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 --- src-ui/jest.config.js | 14 +- src-ui/package-lock.json | 20 + src-ui/package.json | 3 +- src-ui/setup-jest.ts | 57 + src-ui/src/app/app-routing.module.ts | 2 +- src-ui/src/app/app.component.spec.ts | 182 ++ .../app-frame/app-frame.component.spec.ts | 272 +++ .../app-frame/app-frame.component.ts | 8 +- .../clearable-badge.component.spec.ts | 43 + .../confirm-dialog.component.spec.ts | 99 + .../date-dropdown.component.spec.ts | 141 ++ .../date-dropdown/date-dropdown.component.ts | 1 - ...orrespondent-edit-dialog.component.spec.ts | 55 + ...ocument-type-edit-dialog.component.spec.ts | 55 + .../edit-dialog/edit-dialog.component.spec.ts | 234 +++ .../edit-dialog/edit-dialog.component.ts | 21 +- .../group-edit-dialog.component.spec.ts | 57 + ...mail-account-edit-dialog.component.spec.ts | 117 ++ .../mail-rule-edit-dialog.component.spec.ts | 113 ++ ...storage-path-edit-dialog.component.spec.ts | 57 + .../tag-edit-dialog.component.spec.ts | 59 + .../user-edit-dialog.component.spec.ts | 115 ++ .../filterable-dropdown.component.html | 2 +- .../filterable-dropdown.component.spec.ts | 487 +++++ .../filterable-dropdown.component.ts | 2 +- ...ggleable-dropdown-button.component.spec.ts | 79 + .../common/input/abstract-input.spec.ts | 55 + .../common/input/check/check.component.html | 2 +- .../input/check/check.component.spec.ts | 39 + .../common/input/check/check.component.ts | 5 +- .../common/input/color/color.component.html | 2 +- .../input/color/color.component.spec.ts | 72 + .../common/input/date/date.component.html | 2 +- .../common/input/date/date.component.spec.ts | 103 + .../common/input/number/number.component.html | 2 +- .../input/number/number.component.spec.ts | 79 + .../input/password/password.component.spec.ts | 36 + .../permissions-form.component.spec.ts | 66 + .../permissions-group.component.spec.ts | 59 + .../permissions-user.component.spec.ts | 60 + .../input/select/select.component.spec.ts | 121 ++ .../common/input/tags/tags.component.spec.ts | 140 ++ .../common/input/tags/tags.component.ts | 3 +- .../common/input/text/text.component.spec.ts | 36 + .../page-header/page-header.component.spec.ts | 36 + .../permissions-dialog.component.spec.ts | 90 + ...missions-filter-dropdown.component.spec.ts | 157 ++ .../permissions-select.component.spec.ts | 96 + .../select-dialog.component.spec.ts | 31 + .../common/tag/tag.component.spec.ts | 51 + .../common/toasts/toasts.component.spec.ts | 94 + .../common/toasts/toasts.component.ts | 2 +- .../dashboard/dashboard.component.spec.ts | 117 ++ .../saved-view-widget.component.spec.ts | 165 ++ .../statistics-widget.component.spec.ts | 110 ++ .../upload-file-widget.component.spec.ts | 173 ++ .../upload-file-widget.component.ts | 3 - .../welcome-widget.component.spec.ts | 33 + .../widget-frame.component.spec.ts | 53 + .../document-asn.component.spec.ts | 58 + .../document-detail.component.spec.ts | 770 ++++++++ .../document-detail.component.ts | 12 +- .../metadata-collapse.component.spec.ts | 51 + .../bulk-editor/bulk-editor.component.spec.ts | 869 +++++++++ .../document-card-large.component.spec.ts | 129 ++ .../document-card-large.component.ts | 2 +- .../document-card-small.component.spec.ts | 120 ++ .../document-list.component.spec.ts | 591 ++++++ .../document-list/document-list.component.ts | 4 +- .../filter-editor.component.spec.ts | 1672 +++++++++++++++++ .../filter-editor/filter-editor.component.ts | 25 +- .../save-view-config-dialog.component.spec.ts | 89 + .../document-notes.component.spec.ts | 187 ++ .../document-notes.component.ts | 5 +- .../correspondent-list.component.spec.ts | 70 + .../document-type-list.component.spec.ts | 68 + .../manage/logs/logs.component.spec.ts | 71 + .../management-list.component.spec.ts | 232 +++ .../management-list.component.ts | 13 +- .../settings/settings.component.spec.ts | 484 +++++ .../manage/settings/settings.component.ts | 27 +- .../storage-path-list.component.spec.ts | 68 + .../tag-list/tag-list.component.spec.ts | 70 + .../manage/tasks/tasks.component.html | 8 +- .../manage/tasks/tasks.component.spec.ts | 272 +++ .../manage/tasks/tasks.component.ts | 10 +- .../not-found/not-found.component.spec.ts | 23 + .../with-permissions.component.spec.ts | 18 + src-ui/src/app/data/filter-rule.ts | 46 - .../if-object-permissions.directive.spec.ts | 63 + .../app/directives/if-owner.directive.spec.ts | 56 + .../if-permissions.directive.spec.ts | 54 + .../app/directives/sortable.directive.spec.ts | 93 + src-ui/src/app/guards/dirty-doc.guard.spec.ts | 48 + .../src/app/guards/dirty-form.guard.spec.ts | 65 + .../app/guards/dirty-saved-view.guard.spec.ts | 131 ++ .../src/app/guards/permissions.guard.spec.ts | 100 + .../api-version.interceptor.spec.ts | 30 + .../app/interceptors/csrf.interceptor.spec.ts | 37 +- .../src/app/interceptors/csrf.interceptor.ts | 2 +- src-ui/src/app/pipes/custom-date.pipe.spec.ts | 33 + .../src/app/pipes/document-title.pipe.spec.ts | 5 +- src-ui/src/app/pipes/file-size.pipe.spec.ts | 16 +- src-ui/src/app/pipes/filter.pipe.spec.ts | 28 + src-ui/src/app/pipes/safehtml.pipe.spec.ts | 24 + src-ui/src/app/pipes/safeurl.pipe.spec.ts | 32 + src-ui/src/app/pipes/username.pipe.spec.ts | 73 + src-ui/src/app/pipes/yes-no.pipe.spec.ts | 5 +- .../services/consumer-status.service.spec.ts | 254 +++ .../document-list-view.service.spec.ts | 428 +++++ .../services/document-list-view.service.ts | 4 +- .../services/open-documents.service.spec.ts | 224 +++ .../app/services/permissions.service.spec.ts | 405 ++++ .../src/app/services/permissions.service.ts | 5 +- .../rest/abstract-name-filter-service.spec.ts | 60 + .../rest/abstract-paperless-service.spec.ts | 116 ++ .../rest/abstract-paperless-service.ts | 4 +- .../rest/correspondent.service.spec.ts | 7 + .../rest/document-notes.service.spec.ts | 79 + .../rest/document-type.service.spec.ts | 7 + .../services/rest/document.service.spec.ts | 247 +++ .../app/services/rest/group.service.spec.ts | 192 ++ .../src/app/services/rest/log.service.spec.ts | 47 + .../rest/mail-account.service.spec.ts | 80 + .../services/rest/mail-rule.service.spec.ts | 92 + .../rest/remote-version.service.spec.ts | 38 + .../services/rest/saved-view.service.spec.ts | 81 + .../app/services/rest/search.service.spec.ts | 39 + .../rest/storage-path.service.spec.ts | 7 + .../src/app/services/rest/tag.service.spec.ts | 4 + .../app/services/rest/user.service.spec.ts | 193 ++ .../src/app/services/settings.service.spec.ts | 212 +++ src-ui/src/app/services/settings.service.ts | 38 +- src-ui/src/app/services/tasks.service.spec.ts | 110 ++ src-ui/src/app/services/tasks.service.ts | 2 +- src-ui/src/app/services/toast.service.spec.ts | 57 + .../services/upload-documents.service.spec.ts | 169 ++ .../app/services/upload-documents.service.ts | 4 +- src-ui/src/app/utils/color.spec.ts | 50 + src-ui/src/app/utils/filter-rules.spec.ts | 48 + src-ui/src/app/utils/filter-rules.ts | 46 + .../utils/ngb-date-parser-formatter.spec.ts | 67 + .../app/utils/ngb-iso-date-adapter.spec.ts | 40 + src-ui/src/app/utils/query-params.spec.ts | 196 ++ src-ui/src/app/utils/query-params.ts | 2 +- 145 files changed, 14832 insertions(+), 169 deletions(-) create mode 100644 src-ui/src/app/app.component.spec.ts create mode 100644 src-ui/src/app/components/app-frame/app-frame.component.spec.ts create mode 100644 src-ui/src/app/components/common/clearable-badge/clearable-badge.component.spec.ts create mode 100644 src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/date-dropdown/date-dropdown.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts create mode 100644 src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/abstract-input.spec.ts create mode 100644 src-ui/src/app/components/common/input/check/check.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/color/color.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/date/date.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/number/number.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/password/password.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/permissions/permissions-form/permissions-form.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/select/select.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/tags/tags.component.spec.ts create mode 100644 src-ui/src/app/components/common/input/text/text.component.spec.ts create mode 100644 src-ui/src/app/components/common/page-header/page-header.component.spec.ts create mode 100644 src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.spec.ts create mode 100644 src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts create mode 100644 src-ui/src/app/components/common/select-dialog/select-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/tag/tag.component.spec.ts create mode 100644 src-ui/src/app/components/common/toasts/toasts.component.spec.ts create mode 100644 src-ui/src/app/components/dashboard/dashboard.component.spec.ts create mode 100644 src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts create mode 100644 src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts create mode 100644 src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts create mode 100644 src-ui/src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.spec.ts create mode 100644 src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.spec.ts create mode 100644 src-ui/src/app/components/document-asn/document-asn.component.spec.ts create mode 100644 src-ui/src/app/components/document-detail/document-detail.component.spec.ts create mode 100644 src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts create mode 100644 src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts create mode 100644 src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts create mode 100644 src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts create mode 100644 src-ui/src/app/components/document-list/document-list.component.spec.ts create mode 100644 src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts create mode 100644 src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/document-notes/document-notes.component.spec.ts create mode 100644 src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.spec.ts create mode 100644 src-ui/src/app/components/manage/document-type-list/document-type-list.component.spec.ts create mode 100644 src-ui/src/app/components/manage/logs/logs.component.spec.ts create mode 100644 src-ui/src/app/components/manage/management-list/management-list.component.spec.ts create mode 100644 src-ui/src/app/components/manage/settings/settings.component.spec.ts create mode 100644 src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.spec.ts create mode 100644 src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts create mode 100644 src-ui/src/app/components/manage/tasks/tasks.component.spec.ts create mode 100644 src-ui/src/app/components/not-found/not-found.component.spec.ts create mode 100644 src-ui/src/app/components/with-permissions/with-permissions.component.spec.ts create mode 100644 src-ui/src/app/directives/if-object-permissions.directive.spec.ts create mode 100644 src-ui/src/app/directives/if-owner.directive.spec.ts create mode 100644 src-ui/src/app/directives/if-permissions.directive.spec.ts create mode 100644 src-ui/src/app/directives/sortable.directive.spec.ts create mode 100644 src-ui/src/app/guards/dirty-doc.guard.spec.ts create mode 100644 src-ui/src/app/guards/dirty-form.guard.spec.ts create mode 100644 src-ui/src/app/guards/dirty-saved-view.guard.spec.ts create mode 100644 src-ui/src/app/guards/permissions.guard.spec.ts create mode 100644 src-ui/src/app/interceptors/api-version.interceptor.spec.ts create mode 100644 src-ui/src/app/pipes/custom-date.pipe.spec.ts create mode 100644 src-ui/src/app/pipes/filter.pipe.spec.ts create mode 100644 src-ui/src/app/pipes/safehtml.pipe.spec.ts create mode 100644 src-ui/src/app/pipes/safeurl.pipe.spec.ts create mode 100644 src-ui/src/app/pipes/username.pipe.spec.ts create mode 100644 src-ui/src/app/services/consumer-status.service.spec.ts create mode 100644 src-ui/src/app/services/document-list-view.service.spec.ts create mode 100644 src-ui/src/app/services/open-documents.service.spec.ts create mode 100644 src-ui/src/app/services/permissions.service.spec.ts create mode 100644 src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts create mode 100644 src-ui/src/app/services/rest/abstract-paperless-service.spec.ts create mode 100644 src-ui/src/app/services/rest/correspondent.service.spec.ts create mode 100644 src-ui/src/app/services/rest/document-notes.service.spec.ts create mode 100644 src-ui/src/app/services/rest/document-type.service.spec.ts create mode 100644 src-ui/src/app/services/rest/document.service.spec.ts create mode 100644 src-ui/src/app/services/rest/group.service.spec.ts create mode 100644 src-ui/src/app/services/rest/log.service.spec.ts create mode 100644 src-ui/src/app/services/rest/mail-account.service.spec.ts create mode 100644 src-ui/src/app/services/rest/mail-rule.service.spec.ts create mode 100644 src-ui/src/app/services/rest/remote-version.service.spec.ts create mode 100644 src-ui/src/app/services/rest/saved-view.service.spec.ts create mode 100644 src-ui/src/app/services/rest/search.service.spec.ts create mode 100644 src-ui/src/app/services/rest/storage-path.service.spec.ts create mode 100644 src-ui/src/app/services/rest/tag.service.spec.ts create mode 100644 src-ui/src/app/services/rest/user.service.spec.ts create mode 100644 src-ui/src/app/services/settings.service.spec.ts create mode 100644 src-ui/src/app/services/tasks.service.spec.ts create mode 100644 src-ui/src/app/services/toast.service.spec.ts create mode 100644 src-ui/src/app/services/upload-documents.service.spec.ts create mode 100644 src-ui/src/app/utils/color.spec.ts create mode 100644 src-ui/src/app/utils/filter-rules.spec.ts create mode 100644 src-ui/src/app/utils/filter-rules.ts create mode 100644 src-ui/src/app/utils/ngb-date-parser-formatter.spec.ts create mode 100644 src-ui/src/app/utils/ngb-iso-date-adapter.spec.ts create mode 100644 src-ui/src/app/utils/query-params.spec.ts diff --git a/src-ui/jest.config.js b/src-ui/jest.config.js index 23de7b188..14892964e 100644 --- a/src-ui/jest.config.js +++ b/src-ui/jest.config.js @@ -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', + }, } diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json index dff374c03..71d98cc15 100644 --- a/src-ui/package-lock.json +++ b/src-ui/package-lock.json @@ -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" @@ -12307,6 +12308,16 @@ "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", @@ -13454,6 +13465,15 @@ "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", diff --git a/src-ui/package.json b/src-ui/package.json index 20578394c..0da23eb5c 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -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" diff --git a/src-ui/setup-jest.ts b/src-ui/setup-jest.ts index 876946e2b..628c2a74c 100644 --- a/src-ui/setup-jest.ts +++ b/src-ui/setup-jest.ts @@ -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 { diff --git a/src-ui/src/app/app-routing.module.ts b/src-ui/src/app/app-routing.module.ts index 4d12ee4f3..d839274f8 100644 --- a/src-ui/src/app/app-routing.module.ts +++ b/src-ui/src/app/app-routing.module.ts @@ -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 index 000000000..57c0e1afb --- /dev/null +++ b/src-ui/src/app/app.component.spec.ts @@ -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 index 000000000..c685413f3 --- /dev/null +++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts @@ -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(), + }, + ]) + }) +}) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.ts b/src-ui/src/app/components/app-frame/app-frame.component.ts index cff4366da..930b74de3 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.ts @@ -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 index 000000000..90c3f6061 --- /dev/null +++ b/src-ui/src/app/components/common/clearable-badge/clearable-badge.component.spec.ts @@ -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 index 000000000..e2c16dbf8 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts @@ -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 index 000000000..b98b7137d --- /dev/null +++ b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts index 423bf4a68..b1493a7d3 100644 --- a/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts +++ b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts @@ -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 index 000000000..843725ba8 --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.spec.ts @@ -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 index 000000000..676073acf --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.spec.ts @@ -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 index 000000000..18816d0d1 --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts @@ -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') + }) +}) diff --git a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts index 07693ce5a..8924a0ac8 100644 --- a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts @@ -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 index 000000000..e762bbda7 --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.spec.ts @@ -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 index 000000000..93fa7f0fd --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts @@ -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 index 000000000..5d089005b --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts @@ -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 index 000000000..f44092765 --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts @@ -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 index 000000000..73f3dd908 --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.spec.ts @@ -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 index 000000000..1418c74eb --- /dev/null +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html index cef3690d1..4ee05f3b2 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html @@ -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 index 000000000..42adfdba3 --- /dev/null +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts @@ -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]]) + }) +}) diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts index 79548aaf8..e0c363fdb 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts @@ -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 index 000000000..712c1c9bd --- /dev/null +++ b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.spec.ts @@ -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 index 000000000..f3836f9d7 --- /dev/null +++ b/src-ui/src/app/components/common/input/abstract-input.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/components/common/input/check/check.component.html b/src-ui/src/app/components/common/input/check/check.component.html index 62de7d143..51d5250b4 100644 --- a/src-ui/src/app/components/common/input/check/check.component.html +++ b/src-ui/src/app/components/common/input/check/check.component.html @@ -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 index 000000000..7008c4811 --- /dev/null +++ b/src-ui/src/app/components/common/input/check/check.component.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/components/common/input/check/check.component.ts b/src-ui/src/app/components/common/input/check/check.component.ts index f4a6e527e..181542142 100644 --- a/src-ui/src/app/components/common/input/check/check.component.ts +++ b/src-ui/src/app/components/common/input/check/check.component.ts @@ -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({ diff --git a/src-ui/src/app/components/common/input/color/color.component.html b/src-ui/src/app/components/common/input/color/color.component.html index 08cbf0bab..f905f5e7c 100644 --- a/src-ui/src/app/components/common/input/color/color.component.html +++ b/src-ui/src/app/components/common/input/color/color.component.html @@ -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 index 000000000..3e7b0dae4 --- /dev/null +++ b/src-ui/src/app/components/common/input/color/color.component.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/components/common/input/date/date.component.html b/src-ui/src/app/components/common/input/date/date.component.html index 38f2bb5a9..66cdb5092 100644 --- a/src-ui/src/app/components/common/input/date/date.component.html +++ b/src-ui/src/app/components/common/input/date/date.component.html @@ -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 index 000000000..2b5467412 --- /dev/null +++ b/src-ui/src/app/components/common/input/date/date.component.spec.ts @@ -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 }) + }) +}) diff --git a/src-ui/src/app/components/common/input/number/number.component.html b/src-ui/src/app/components/common/input/number/number.component.html index 85b1012aa..1e1f1237c 100644 --- a/src-ui/src/app/components/common/input/number/number.component.html +++ b/src-ui/src/app/components/common/input/number/number.component.html @@ -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 index 000000000..bf1b40d38 --- /dev/null +++ b/src-ui/src/app/components/common/input/number/number.component.spec.ts @@ -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 index 000000000..80ad853d7 --- /dev/null +++ b/src-ui/src/app/components/common/input/password/password.component.spec.ts @@ -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 index 000000000..bad414a8d --- /dev/null +++ b/src-ui/src/app/components/common/input/permissions/permissions-form/permissions-form.component.spec.ts @@ -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 index 000000000..7de1490ee --- /dev/null +++ b/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts @@ -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 index 000000000..f30c009e0 --- /dev/null +++ b/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts @@ -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 index 000000000..554175cac --- /dev/null +++ b/src-ui/src/app/components/common/input/select/select.component.spec.ts @@ -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 index 000000000..f3ea05d5d --- /dev/null +++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts index 2b596f640..ea92488f9 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.ts @@ -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 index 000000000..4b0a13bc3 --- /dev/null +++ b/src-ui/src/app/components/common/input/text/text.component.spec.ts @@ -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 index 000000000..cff375429 --- /dev/null +++ b/src-ui/src/app/components/common/page-header/page-header.component.spec.ts @@ -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 index 000000000..bf9b55c95 --- /dev/null +++ b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts @@ -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 index 000000000..add538dc2 --- /dev/null +++ b/src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.spec.ts @@ -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 index 000000000..db20535bf --- /dev/null +++ b/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts @@ -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 index 000000000..cd4c0dcc3 --- /dev/null +++ b/src-ui/src/app/components/common/select-dialog/select-dialog.component.spec.ts @@ -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 index 000000000..300dbdece --- /dev/null +++ b/src-ui/src/app/components/common/tag/tag.component.spec.ts @@ -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 index 000000000..3891347d2 --- /dev/null +++ b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts @@ -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() + })) +}) diff --git a/src-ui/src/app/components/common/toasts/toasts.component.ts b/src-ui/src/app/components/common/toasts/toasts.component.ts index ac7a693d6..9d013c88a 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.ts +++ b/src-ui/src/app/components/common/toasts/toasts.component.ts @@ -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 index 000000000..911565526 --- /dev/null +++ b/src-ui/src/app/components/dashboard/dashboard.component.spec.ts @@ -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 index 000000000..27ba45fae --- /dev/null +++ b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts @@ -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 index 000000000..da07b7186 --- /dev/null +++ b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts @@ -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 index 000000000..70bd98302 --- /dev/null +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts @@ -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(), + ] + }) +} diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts index 2c6bf38e2..96c8cdb25 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts @@ -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 index 000000000..07f0658ae --- /dev/null +++ b/src-ui/src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.spec.ts @@ -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 index 000000000..59089deb4 --- /dev/null +++ b/src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.spec.ts @@ -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 index 000000000..62b1113db --- /dev/null +++ b/src-ui/src/app/components/document-asn/document-asn.component.spec.ts @@ -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 index 000000000..0e6a99d89 --- /dev/null +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -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() + } +}) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 31e477d76..654f68a50 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -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 index 000000000..c2eab4290 --- /dev/null +++ b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts @@ -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 index 000000000..bb95a9c54 --- /dev/null +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -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 index 000000000..407bb01c4 --- /dev/null +++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts @@ -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>') + }) +}) diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts index d2153fb62..8d2a59405 100644 --- a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts +++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts @@ -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 index 000000000..67081cbd5 --- /dev/null +++ b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts @@ -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 index 000000000..2b14747bf --- /dev/null +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -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' }, + ]) + }) +}) diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index 3ffee2efa..32431167b 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -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 index 000000000..e499a36f5 --- /dev/null +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts @@ -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('') + }) +}) diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts index 37a58c54c..64d237f2c 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -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 index 000000000..f1412ce21 --- /dev/null +++ b/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.spec.ts @@ -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 index 000000000..dbe3933be --- /dev/null +++ b/src-ui/src/app/components/document-notes/document-notes.component.spec.ts @@ -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 + ) + }) +}) diff --git a/src-ui/src/app/components/document-notes/document-notes.component.ts b/src-ui/src/app/components/document-notes/document-notes.component.ts index c005b917c..b8c7d6fd9 100644 --- a/src-ui/src/app/components/document-notes/document-notes.component.ts +++ b/src-ui/src/app/components/document-notes/document-notes.component.ts @@ -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 index 000000000..4f68a609b --- /dev/null +++ b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.spec.ts @@ -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 index 000000000..1642d89b9 --- /dev/null +++ b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.spec.ts @@ -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 index 000000000..a9c4b4613 --- /dev/null +++ b/src-ui/src/app/components/manage/logs/logs.component.spec.ts @@ -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 index 000000000..9579e5bd8 --- /dev/null +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index 8264f7071..b45c234ea 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -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 index 000000000..f9f423fea --- /dev/null +++ b/src-ui/src/app/components/manage/settings/settings.component.spec.ts @@ -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') + }) +}) diff --git a/src-ui/src/app/components/manage/settings/settings.component.ts b/src-ui/src/app/components/manage/settings/settings.component.ts index dc4f1746d..c75867f7e 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.ts @@ -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 index 000000000..5571c443d --- /dev/null +++ b/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.spec.ts @@ -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 index 000000000..e7be9035e --- /dev/null +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts @@ -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"?' + ) + }) +}) diff --git a/src-ui/src/app/components/manage/tasks/tasks.component.html b/src-ui/src/app/components/manage/tasks/tasks.component.html index b27275712..f1d728e72 100644 --- a/src-ui/src/app/components/manage/tasks/tasks.component.html +++ b/src-ui/src/app/components/manage/tasks/tasks.component.html @@ -107,25 +107,25 @@ <ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)"> <li ngbNavItem="failed"> - <a ngbNavLink i18n>Failed <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 <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 <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 <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 index 000000000..c981dac6f --- /dev/null +++ b/src-ui/src/app/components/manage/tasks/tasks.component.spec.ts @@ -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, + ]) + }) +}) diff --git a/src-ui/src/app/components/manage/tasks/tasks.component.ts b/src-ui/src/app/components/manage/tasks/tasks.component.ts index 5c89967aa..9e993de62 100644 --- a/src-ui/src/app/components/manage/tasks/tasks.component.ts +++ b/src-ui/src/app/components/manage/tasks/tasks.component.ts @@ -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 index 000000000..8962d833f --- /dev/null +++ b/src-ui/src/app/components/not-found/not-found.component.spec.ts @@ -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 index 000000000..8d097d5ae --- /dev/null +++ b/src-ui/src/app/components/with-permissions/with-permissions.component.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/data/filter-rule.ts b/src-ui/src/app/data/filter-rule.ts index b0b9cb415..c52261c25 100644 --- a/src-ui/src/app/data/filter-rule.ts +++ b/src-ui/src/app/data/filter-rule.ts @@ -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 index 000000000..ceda02df5 --- /dev/null +++ b/src-ui/src/app/directives/if-object-permissions.directive.spec.ts @@ -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 index 000000000..8e5d4fed2 --- /dev/null +++ b/src-ui/src/app/directives/if-owner.directive.spec.ts @@ -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 index 000000000..8dc926f05 --- /dev/null +++ b/src-ui/src/app/directives/if-permissions.directive.spec.ts @@ -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 index 000000000..65f317ad2 --- /dev/null +++ b/src-ui/src/app/directives/sortable.directive.spec.ts @@ -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 index 000000000..1b99c1ba7 --- /dev/null +++ b/src-ui/src/app/guards/dirty-doc.guard.spec.ts @@ -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 index 000000000..24ee24f74 --- /dev/null +++ b/src-ui/src/app/guards/dirty-form.guard.spec.ts @@ -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 index 000000000..f4388d91f --- /dev/null +++ b/src-ui/src/app/guards/dirty-saved-view.guard.spec.ts @@ -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 index 000000000..76b7c3929 --- /dev/null +++ b/src-ui/src/app/guards/permissions.guard.spec.ts @@ -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 index 000000000..e3a2d7e5d --- /dev/null +++ b/src-ui/src/app/interceptors/api-version.interceptor.spec.ts @@ -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>) + }, + }) + }) +}) diff --git a/src-ui/src/app/interceptors/csrf.interceptor.spec.ts b/src-ui/src/app/interceptors/csrf.interceptor.spec.ts index df387acc1..78ebeb318 100644 --- a/src-ui/src/app/interceptors/csrf.interceptor.spec.ts +++ b/src-ui/src/app/interceptors/csrf.interceptor.spec.ts @@ -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(() => - TestBed.configureTestingModule({ - providers: [CsrfInterceptor], - }) - ) + let interceptor: CsrfInterceptor + let meta: Meta + let cookieService: CookieService - it('should be created', () => { - const interceptor: CsrfInterceptor = TestBed.inject(CsrfInterceptor) - expect(interceptor).toBeTruthy() + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [CsrfInterceptor, Meta, CookieService], + }) + + 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() }) }) diff --git a/src-ui/src/app/interceptors/csrf.interceptor.ts b/src-ui/src/app/interceptors/csrf.interceptor.ts index eb136e489..7408938b9 100644 --- a/src-ui/src/app/interceptors/csrf.interceptor.ts +++ b/src-ui/src/app/interceptors/csrf.interceptor.ts @@ -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 index 000000000..5b9d0b176 --- /dev/null +++ b/src-ui/src/app/pipes/custom-date.pipe.spec.ts @@ -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') + }) +}) diff --git a/src-ui/src/app/pipes/document-title.pipe.spec.ts b/src-ui/src/app/pipes/document-title.pipe.spec.ts index 08afd244e..0ef68bf3e 100644 --- a/src-ui/src/app/pipes/document-title.pipe.spec.ts +++ b/src-ui/src/app/pipes/document-title.pipe.spec.ts @@ -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)') }) }) diff --git a/src-ui/src/app/pipes/file-size.pipe.spec.ts b/src-ui/src/app/pipes/file-size.pipe.spec.ts index b84500fad..42737ae9b 100644 --- a/src-ui/src/app/pipes/file-size.pipe.spec.ts +++ b/src-ui/src/app/pipes/file-size.pipe.spec.ts @@ -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 index 000000000..9c4bfb372 --- /dev/null +++ b/src-ui/src/app/pipes/filter.pipe.spec.ts @@ -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 index 000000000..28fb9deed --- /dev/null +++ b/src-ui/src/app/pipes/safehtml.pipe.spec.ts @@ -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 index 000000000..cf34a0d6d --- /dev/null +++ b/src-ui/src/app/pipes/safeurl.pipe.spec.ts @@ -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 index 000000000..cab2116d6 --- /dev/null +++ b/src-ui/src/app/pipes/username.pipe.spec.ts @@ -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') + }) +}) diff --git a/src-ui/src/app/pipes/yes-no.pipe.spec.ts b/src-ui/src/app/pipes/yes-no.pipe.spec.ts index 3da9518e9..1309790a4 100644 --- a/src-ui/src/app/pipes/yes-no.pipe.spec.ts +++ b/src-ui/src/app/pipes/yes-no.pipe.spec.ts @@ -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 index 000000000..5c93f5160 --- /dev/null +++ b/src-ui/src/app/services/consumer-status.service.spec.ts @@ -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 index 000000000..2a63e21c9 --- /dev/null +++ b/src-ui/src/app/services/document-list-view.service.spec.ts @@ -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) + }) +}) diff --git a/src-ui/src/app/services/document-list-view.service.ts b/src-ui/src/app/services/document-list-view.service.ts index 34404d357..730bea23a 100644 --- a/src-ui/src/app/services/document-list-view.service.ts +++ b/src-ui/src/app/services/document-list-view.service.ts @@ -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 index 000000000..3c8e29edd --- /dev/null +++ b/src-ui/src/app/services/open-documents.service.spec.ts @@ -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 index 000000000..080326b17 --- /dev/null +++ b/src-ui/src/app/services/permissions.service.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts index 1e7b0d031..a14e4bc1f 100644 --- a/src-ui/src/app/services/permissions.service.ts +++ b/src-ui/src/app/services/permissions.service.ts @@ -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 index 000000000..e4ec93aeb --- /dev/null +++ b/src-ui/src/app/services/rest/abstract-name-filter-service.spec.ts @@ -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 index 000000000..92ff923f4 --- /dev/null +++ b/src-ui/src/app/services/rest/abstract-paperless-service.spec.ts @@ -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() + }) +} diff --git a/src-ui/src/app/services/rest/abstract-paperless-service.ts b/src-ui/src/app/services/rest/abstract-paperless-service.ts index 9a5664c9d..96de58e2d 100644 --- a/src-ui/src/app/services/rest/abstract-paperless-service.ts +++ b/src-ui/src/app/services/rest/abstract-paperless-service.ts @@ -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 index 000000000..94cfa9c07 --- /dev/null +++ b/src-ui/src/app/services/rest/correspondent.service.spec.ts @@ -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 index 000000000..c07623042 --- /dev/null +++ b/src-ui/src/app/services/rest/document-notes.service.spec.ts @@ -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 index 000000000..9fc91514e --- /dev/null +++ b/src-ui/src/app/services/rest/document-type.service.spec.ts @@ -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 index 000000000..be1128de4 --- /dev/null +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -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 index 000000000..98183b589 --- /dev/null +++ b/src-ui/src/app/services/rest/group.service.spec.ts @@ -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 index 000000000..234c94b6c --- /dev/null +++ b/src-ui/src/app/services/rest/log.service.spec.ts @@ -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 index 000000000..86e09e241 --- /dev/null +++ b/src-ui/src/app/services/rest/mail-account.service.spec.ts @@ -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 index 000000000..daff57341 --- /dev/null +++ b/src-ui/src/app/services/rest/mail-rule.service.spec.ts @@ -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 index 000000000..f816e2d93 --- /dev/null +++ b/src-ui/src/app/services/rest/remote-version.service.spec.ts @@ -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 index 000000000..1e0c761aa --- /dev/null +++ b/src-ui/src/app/services/rest/saved-view.service.spec.ts @@ -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 index 000000000..7f42aa7da --- /dev/null +++ b/src-ui/src/app/services/rest/search.service.spec.ts @@ -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 index 000000000..f365f6aa1 --- /dev/null +++ b/src-ui/src/app/services/rest/storage-path.service.spec.ts @@ -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 index 000000000..9b28fc9b6 --- /dev/null +++ b/src-ui/src/app/services/rest/tag.service.spec.ts @@ -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 index 000000000..acf66340a --- /dev/null +++ b/src-ui/src/app/services/rest/user.service.spec.ts @@ -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 index 000000000..560b67ae8 --- /dev/null +++ b/src-ui/src/app/services/settings.service.spec.ts @@ -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') + }) +}) diff --git a/src-ui/src/app/services/settings.service.ts b/src-ui/src/app/services/settings.service.ts index 207ccba56..f47c6b7e9 100644 --- a/src-ui/src/app/services/settings.service.ts +++ b/src-ui/src/app/services/settings.service.ts @@ -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 index 000000000..c41b69f24 --- /dev/null +++ b/src-ui/src/app/services/tasks.service.spec.ts @@ -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) + }) +}) diff --git a/src-ui/src/app/services/tasks.service.ts b/src-ui/src/app/services/tasks.service.ts index 4607128a1..9dfd118e7 100644 --- a/src-ui/src/app/services/tasks.service.ts +++ b/src-ui/src/app/services/tasks.service.ts @@ -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 index 000000000..274ea9db6 --- /dev/null +++ b/src-ui/src/app/services/toast.service.spec.ts @@ -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 index 000000000..0c3ab8033 --- /dev/null +++ b/src-ui/src/app/services/upload-documents.service.spec.ts @@ -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) + }) +}) diff --git a/src-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts index f214a3cb5..5e7ef7fbe 100644 --- a/src-ui/src/app/services/upload-documents.service.ts +++ b/src-ui/src/app/services/upload-documents.service.ts @@ -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 index 000000000..4dddb59a1 --- /dev/null +++ b/src-ui/src/app/utils/color.spec.ts @@ -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 index 000000000..b02218635 --- /dev/null +++ b/src-ui/src/app/utils/filter-rules.spec.ts @@ -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 index 000000000..14e57a6cc --- /dev/null +++ b/src-ui/src/app/utils/filter-rules.ts @@ -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 index 000000000..b8531bbb3 --- /dev/null +++ b/src-ui/src/app/utils/ngb-date-parser-formatter.spec.ts @@ -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 index 000000000..6ed18f8a5 --- /dev/null +++ b/src-ui/src/app/utils/ngb-iso-date-adapter.spec.ts @@ -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 index 000000000..a1bc0cdcd --- /dev/null +++ b/src-ui/src/app/utils/query-params.spec.ts @@ -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, + }, + ]) + }) +}) diff --git a/src-ui/src/app/utils/query-params.ts b/src-ui/src/app/utils/query-params.ts index b98801f7b..1121bd6a3 100644 --- a/src-ui/src/app/utils/query-params.ts +++ b/src-ui/src/app/utils/query-params.ts @@ -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 } From 37e928d8696e65a8d14f9c43333fea9a4c57a2a6 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 26 May 2023 11:32:41 -0700 Subject: [PATCH 02/12] Run jest tests in ci & upload coverage update playwright --- .github/workflows/ci.yml | 14 ++++++++++++-- src-ui/package-lock.json | 20 ++++++++++---------- src-ui/package.json | 2 +- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8c9bb533..fe06fe25e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,10 +202,20 @@ jobs: name: Linting checks run: cd src-ui && npm run lint - - name: Run Playwright tests + name: Run Jest unit tests + run: cd src-ui && npm run test + - + name: Upload Jest coverage + if: always() + uses: actions/upload-artifact@v3 + with: + name: jest-coverage-report + path: src-ui/coverage + - + name: Run Playwright e2e tests run: cd src-ui && npx playwright test - - name: Upload test results + name: Upload Playwright test results if: always() uses: actions/upload-artifact@v3 with: diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json index 71d98cc15..7134bd48d 100644 --- a/src-ui/package-lock.json +++ b/src-ui/package-lock.json @@ -43,7 +43,7 @@ "@angular-eslint/template-parser": "15.2.1", "@angular/cli": "~15.2.7", "@angular/compiler-cli": "~15.2.8", - "@playwright/test": "^1.34.3", + "@playwright/test": "^1.35.1", "@types/jest": "^29.5.0", "@types/node": "^20.2.5", "@typescript-eslint/eslint-plugin": "^5.59.8", @@ -4229,19 +4229,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.34.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.3.tgz", - "integrity": "sha512-zPLef6w9P6T/iT6XDYG3mvGOqOyb6eHaV9XtkunYs0+OzxBtrPAAaHotc0X+PJ00WPPnLfFBTl7mf45Mn8DBmw==", + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.35.1.tgz", + "integrity": "sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA==", "dev": true, "dependencies": { "@types/node": "*", - "playwright-core": "1.34.3" + "playwright-core": "1.35.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=16" }, "optionalDependencies": { "fsevents": "2.3.2" @@ -14615,15 +14615,15 @@ } }, "node_modules/playwright-core": { - "version": "1.34.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.3.tgz", - "integrity": "sha512-2pWd6G7OHKemc5x1r1rp8aQcpvDh7goMBZlJv6Co5vCNLVcQJdhxRL09SGaY6HcyHH9aT4tiynZabMofVasBYw==", + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz", + "integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==", "dev": true, "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=16" } }, "node_modules/postcss": { diff --git a/src-ui/package.json b/src-ui/package.json index 0da23eb5c..e1ffc6c33 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -45,7 +45,7 @@ "@angular-eslint/template-parser": "15.2.1", "@angular/cli": "~15.2.7", "@angular/compiler-cli": "~15.2.8", - "@playwright/test": "^1.34.3", + "@playwright/test": "^1.35.1", "@types/jest": "^29.5.0", "@types/node": "^20.2.5", "@typescript-eslint/eslint-plugin": "^5.59.8", From 2a240d83fd5153c898ba659fa327e945f8abf39d Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 16 Jun 2023 10:08:36 -0700 Subject: [PATCH 03/12] Reset -dev version tagging --- src-ui/src/environments/environment.prod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index ddde6bb5f..c64beb067 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -5,7 +5,7 @@ export const environment = { apiBaseUrl: document.baseURI + 'api/', apiVersion: '3', appTitle: 'Paperless-ngx', - version: '1.16.1', + version: '1.16.1-dev', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', From 981b090088a7582bed0c67959c89966b61c54059 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 26 May 2023 11:45:21 -0700 Subject: [PATCH 04/12] Update frontend testing dev documentation --- docs/development.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/development.md b/docs/development.md index 66540fd50..799584c0f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -216,19 +216,18 @@ The front end is built using AngularJS. In order to get started, you need Node.j $ git ls-files -- '*.ts' | xargs pre-commit run prettier --files ``` -- Front end testing uses jest and cypress. There is currently a need - for significantly more front end tests. Unit tests and e2e tests, +- Front end testing uses Jest and Playwright. Unit tests and e2e tests, respectively, can be run non-interactively with: ```bash $ ng test - $ npm run e2e:ci + $ npx playwright test ``` - - Cypress also includes a UI which can be run with: + - Playwright also includes a UI which can be run with: ```bash - $ ./node_modules/.bin/cypress open + $ npx playwright test --ui ``` - In order to build the front end and serve it as part of Django, execute: From 77d9a7e9d31fe60063015aa291668900555212ec Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 16 Jun 2023 19:41:53 -0700 Subject: [PATCH 05/12] Upload frontend coverage to codecov --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe06fe25e..efc10c0c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,15 @@ jobs: with: name: jest-coverage-report path: src-ui/coverage + - + name: Upload frontend coverage to Codecov + if: always() + uses: codecov/codecov-action@v3 + with: + # not required for public repos, but intermittently fails otherwise + token: ${{ secrets.CODECOV_TOKEN }} + # future expansion + flags: frontend - name: Run Playwright e2e tests run: cd src-ui && npx playwright test From a1d10e7d4aa473b99d7c3ce36b4789f5797c5515 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 17 Jun 2023 20:02:44 -0700 Subject: [PATCH 06/12] Update stale.yml --- .github/stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index d785c197d..f0af1db2a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -4,8 +4,8 @@ daysUntilStale: 30 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 -# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) -onlyLabels: [cant-reproduce] +# Only issues or pull requests with any of these labels are check if stale. Defaults to `[]` (disabled) +any-of-labels: ['cant-reproduce','not a bug','unconfirmed'] # Label to use when marking an issue as stale staleLabel: stale From 328c87995b3130a45d2cc73872e3480869c5aaa5 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 16 Jun 2023 09:10:08 -0700 Subject: [PATCH 07/12] Experiment with a buildx cache mount for pip's directory. Does it increase image size? --- Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 288593049..43471f6a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.4 +# syntax=docker/dockerfile:1 # https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md # Stage: compile-frontend @@ -216,13 +216,15 @@ ARG BUILD_PACKAGES="\ git \ default-libmysqlclient-dev" -RUN set -eux \ +# hadolint ignore=DL3042 +RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ + set -eux \ && echo "Installing build system packages" \ && apt-get update \ && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ && python3 -m pip install --no-cache-dir --upgrade wheel \ && echo "Installing Python requirements" \ - && python3 -m pip install --default-timeout=1000 --no-cache-dir --requirement requirements.txt \ + && python3 -m pip install --default-timeout=1000 --requirement requirements.txt \ && echo "Installing NLTK data" \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ From 4693632c7d221a443988de261d4cc30da0d6d345 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sun, 18 Jun 2023 06:47:52 +0200 Subject: [PATCH 08/12] Feature: separate save / save & close buttons (#3575) * Add setting to decide whether the edit dialog should automatically close on save * Add the actual button to the ui * Revert "Add the actual button to the ui" This reverts commit e1f5a8bde0e8a2f6bafa2896d6a7e57e70d0c670. * Revert "Add setting to decide whether the edit dialog should automatically close on save" This reverts commit feef3c909b055b4bc4d2884324a4678348e1e536. * Add button for save without exit * Correct save button ordering, ensure perms, update translation strings * fix e2e tests * Add unit testing for save / save & close button --------- Update messages.xlf Update document-detail.component.spec.ts Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com> --- .../document-detail/document-detail.spec.ts | 18 +- src-ui/messages.xlf | 323 +++++++++--------- .../document-detail.component.html | 9 +- .../document-detail.component.spec.ts | 52 ++- .../document-detail.component.ts | 6 +- 5 files changed, 236 insertions(+), 172 deletions(-) diff --git a/src-ui/e2e/document-detail/document-detail.spec.ts b/src-ui/e2e/document-detail/document-detail.spec.ts index a3083f521..d96a02f37 100644 --- a/src-ui/e2e/document-detail/document-detail.spec.ts +++ b/src-ui/e2e/document-detail/document-detail.spec.ts @@ -12,9 +12,13 @@ test('should activate / deactivate save button when changes are saved', async ({ await expect(page.getByTitle('Storage path', { exact: true })).toHaveText( /\w+/ ) - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect( + page.getByRole('button', { name: 'Save', exact: true }) + ).toBeDisabled() await page.getByTitle('Storage path').getByTitle('Clear all').click() - await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() + await expect( + page.getByRole('button', { name: 'Save', exact: true }) + ).toBeEnabled() }) test('should warn on unsaved changes', async ({ page }) => { @@ -23,13 +27,17 @@ test('should warn on unsaved changes', async ({ page }) => { await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText( /\w+/ ) - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect( + page.getByRole('button', { name: 'Save', exact: true }) + ).toBeDisabled() await page .getByTitle('Storage path', { exact: true }) .getByTitle('Clear all') .click() - await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() - await page.getByRole('button', { name: 'Close' }).click() + await expect( + page.getByRole('button', { name: 'Save', exact: true }) + ).toBeEnabled() + await page.getByRole('button', { name: 'Close', exact: true }).click() await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/) await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole('link', { name: 'Close all' }).click() diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index b52c8e702..f8f7e0bbb 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -723,7 +723,7 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">593</context> + <context context-type="linenumber">594</context> </context-group> </trans-unit> <trans-unit id="2526035785704676448" datatype="html"> @@ -814,28 +814,28 @@ <source>Last 7 days</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/common/date-dropdown/date-dropdown.component.ts</context> - <context context-type="linenumber">43</context> + <context context-type="linenumber">42</context> </context-group> </trans-unit> <trans-unit id="4463380307954693363" datatype="html"> <source>Last month</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/common/date-dropdown/date-dropdown.component.ts</context> - <context context-type="linenumber">48</context> + <context context-type="linenumber">47</context> </context-group> </trans-unit> <trans-unit id="8697368973702409683" datatype="html"> <source>Last 3 months</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/common/date-dropdown/date-dropdown.component.ts</context> - <context context-type="linenumber">53</context> + <context context-type="linenumber">52</context> </context-group> </trans-unit> <trans-unit id="3566342898065860218" datatype="html"> <source>Last year</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/common/date-dropdown/date-dropdown.component.ts</context> - <context context-type="linenumber">58</context> + <context context-type="linenumber">57</context> </context-group> </trans-unit> <trans-unit id="8953033926734869941" datatype="html"> @@ -1073,7 +1073,7 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> - <context context-type="linenumber">196</context> + <context context-type="linenumber">198</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context> @@ -1116,21 +1116,14 @@ <source>Create new item</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/common/edit-dialog/edit-dialog.component.ts</context> - <context context-type="linenumber">83</context> + <context context-type="linenumber">88</context> </context-group> </trans-unit> <trans-unit id="5324147361912094446" datatype="html"> <source>Edit item</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/common/edit-dialog/edit-dialog.component.ts</context> - <context context-type="linenumber">87</context> - </context-group> - </trans-unit> - <trans-unit id="1699589597032579396" datatype="html"> - <source>Could not save element: <x id="PH" equiv-text="error"/></source> - <context-group purpose="location"> - <context context-type="sourcefile">src/app/components/common/edit-dialog/edit-dialog.component.ts</context> - <context context-type="linenumber">91</context> + <context context-type="linenumber">92</context> </context-group> </trans-unit> <trans-unit id="7314814725704332646" datatype="html"> @@ -1594,7 +1587,7 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">193</context> + <context context-type="linenumber">194</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context> @@ -2303,7 +2296,7 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">191</context> + <context context-type="linenumber">202</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/rest/document.service.ts</context> @@ -2758,7 +2751,7 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> - <context context-type="linenumber">215</context> + <context context-type="linenumber">218</context> </context-group> </trans-unit> <trans-unit id="8460995830263484763" datatype="html"> @@ -2779,96 +2772,103 @@ <source>Save & next</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> - <context context-type="linenumber">195</context> + <context context-type="linenumber">196</context> + </context-group> + </trans-unit> + <trans-unit id="4910102545766233758" datatype="html"> + <source>Save & close</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> + <context context-type="linenumber">197</context> </context-group> </trans-unit> <trans-unit id="2218903673684131427" datatype="html"> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">254,256</context> + <context context-type="linenumber">252,254</context> </context-group> </trans-unit> <trans-unit id="5758784066858623886" datatype="html"> <source>Error retrieving metadata</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">397</context> + <context context-type="linenumber">395</context> </context-group> </trans-unit> <trans-unit id="3456881259945295697" datatype="html"> <source>Error retrieving suggestions.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">419</context> + <context context-type="linenumber">417</context> </context-group> </trans-unit> <trans-unit id="8348337312757497317" datatype="html"> <source>Document saved successfully.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">531</context> + <context context-type="linenumber">529</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">539</context> + <context context-type="linenumber">537</context> </context-group> </trans-unit> <trans-unit id="448882439049417053" datatype="html"> <source>Error saving document</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">544</context> + <context context-type="linenumber">542</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">589</context> + <context context-type="linenumber">587</context> </context-group> </trans-unit> <trans-unit id="9021887951960049161" datatype="html"> <source>Confirm delete</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">618</context> + <context context-type="linenumber">616</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">189</context> + <context context-type="linenumber">190</context> </context-group> </trans-unit> <trans-unit id="5382975254277698192" datatype="html"> <source>Do you really want to delete document "<x id="PH" equiv-text="this.document.title"/>"?</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">619</context> + <context context-type="linenumber">617</context> </context-group> </trans-unit> <trans-unit id="6691075929777935948" datatype="html"> <source>The files for this document will be deleted permanently. This operation cannot be undone.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">620</context> + <context context-type="linenumber">618</context> </context-group> </trans-unit> <trans-unit id="719892092227206532" datatype="html"> <source>Delete document</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">622</context> + <context context-type="linenumber">620</context> </context-group> </trans-unit> <trans-unit id="1844801255494293730" datatype="html"> <source>Error deleting document: <x id="PH" equiv-text="error.error?.detail ?? error.message ?? JSON.stringify(error)"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">642,644</context> + <context context-type="linenumber">640,642</context> </context-group> </trans-unit> <trans-unit id="7362691899087997122" datatype="html"> <source>Redo OCR confirm</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">665</context> + <context context-type="linenumber">663</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> @@ -2879,14 +2879,14 @@ <source>This operation will permanently redo OCR for this document.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">666</context> + <context context-type="linenumber">664</context> </context-group> </trans-unit> <trans-unit id="5641451190833696892" datatype="html"> <source>This operation cannot be undone.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">667</context> + <context context-type="linenumber">665</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> @@ -2898,26 +2898,26 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">691</context> + <context context-type="linenumber">694</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">749</context> + <context context-type="linenumber">754</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">814</context> + <context context-type="linenumber">821</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">875</context> + <context context-type="linenumber">884</context> </context-group> </trans-unit> <trans-unit id="1181910457994920507" datatype="html"> <source>Proceed</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">669</context> + <context context-type="linenumber">667</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> @@ -2925,26 +2925,26 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">693</context> + <context context-type="linenumber">696</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">751</context> + <context context-type="linenumber">756</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">816</context> + <context context-type="linenumber">823</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">877</context> + <context context-type="linenumber">886</context> </context-group> </trans-unit> <trans-unit id="5729001209753056399" datatype="html"> <source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">677</context> + <context context-type="linenumber">675</context> </context-group> </trans-unit> <trans-unit id="8008978164775353960" datatype="html"> @@ -2953,7 +2953,7 @@ )"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> - <context context-type="linenumber">688,690</context> + <context context-type="linenumber">686,688</context> </context-group> </trans-unit> <trans-unit id="6857598786757174736" datatype="html"> @@ -3499,7 +3499,7 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">196</context> + <context context-type="linenumber">207</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/rest/document.service.ts</context> @@ -3628,133 +3628,147 @@ <source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) => c.id == +rule.value)?.name"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">117,119</context> + <context context-type="linenumber">118,120</context> </context-group> </trans-unit> <trans-unit id="8170755470576301659" datatype="html"> <source>Without correspondent</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">121</context> + <context context-type="linenumber">122</context> </context-group> </trans-unit> - <trans-unit id="8705701325879965907" datatype="html"> - <source>Type: <x id="PH" equiv-text="this.documentTypes.find((dt) => dt.id == +rule.value)?.name"/></source> + <trans-unit id="317796810569008208" datatype="html"> + <source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) => dt.id == +rule.value)?.name"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">126,128</context> + <context context-type="linenumber">128,130</context> </context-group> </trans-unit> <trans-unit id="4362173610367509215" datatype="html"> <source>Without document type</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">130</context> + <context context-type="linenumber">132</context> + </context-group> + </trans-unit> + <trans-unit id="232202047340644471" datatype="html"> + <source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) => sp.id == +rule.value)?.name"/></source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> + <context context-type="linenumber">138,140</context> + </context-group> + </trans-unit> + <trans-unit id="1562820715074533164" datatype="html"> + <source>Without storage path</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> + <context context-type="linenumber">142</context> </context-group> </trans-unit> <trans-unit id="8180755793012580465" datatype="html"> <source>Tag: <x id="PH" equiv-text="this.tags.find((t) => t.id == +rule.value)?.name"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">134,136</context> + <context context-type="linenumber">146,148</context> </context-group> </trans-unit> <trans-unit id="6494566478302448576" datatype="html"> <source>Without any tag</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">140</context> + <context context-type="linenumber">152</context> </context-group> </trans-unit> <trans-unit id="6523384805359286307" datatype="html"> <source>Title: <x id="PH" equiv-text="rule.value"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">144</context> + <context context-type="linenumber">156</context> </context-group> </trans-unit> <trans-unit id="1872523635812236432" datatype="html"> <source>ASN: <x id="PH" equiv-text="rule.value"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">147</context> + <context context-type="linenumber">159</context> </context-group> </trans-unit> <trans-unit id="102674688969746976" datatype="html"> <source>Owner: <x id="PH" equiv-text="rule.value"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">150</context> + <context context-type="linenumber">162</context> </context-group> </trans-unit> <trans-unit id="3550877650686009106" datatype="html"> <source>Owner not in: <x id="PH" equiv-text="rule.value"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">153</context> + <context context-type="linenumber">165</context> </context-group> </trans-unit> <trans-unit id="1082034558646673343" datatype="html"> <source>Without an owner</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">156</context> + <context context-type="linenumber">168</context> </context-group> </trans-unit> <trans-unit id="3100631071441658964" datatype="html"> <source>Title & content</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">194</context> + <context context-type="linenumber">205</context> </context-group> </trans-unit> <trans-unit id="1010505078885609376" datatype="html"> <source>Advanced search</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">199</context> + <context context-type="linenumber">210</context> </context-group> </trans-unit> <trans-unit id="2649431021108393503" datatype="html"> <source>More like</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">205</context> + <context context-type="linenumber">216</context> </context-group> </trans-unit> <trans-unit id="3697582909018473071" datatype="html"> <source>equals</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">224</context> + <context context-type="linenumber">235</context> </context-group> </trans-unit> <trans-unit id="5325481293405718739" datatype="html"> <source>is empty</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">228</context> + <context context-type="linenumber">239</context> </context-group> </trans-unit> <trans-unit id="6166785695326182482" datatype="html"> <source>is not empty</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">232</context> + <context context-type="linenumber">243</context> </context-group> </trans-unit> <trans-unit id="4686622206659266699" datatype="html"> <source>greater than</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">236</context> + <context context-type="linenumber">247</context> </context-group> </trans-unit> <trans-unit id="8014012170270529279" datatype="html"> <source>less than</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> - <context context-type="linenumber">240</context> + <context context-type="linenumber">251</context> </context-group> </trans-unit> <trans-unit id="7210076240260527720" datatype="html"> @@ -3836,14 +3850,14 @@ <source>Error saving note: <x id="PH" equiv-text="e.toString()"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context> - <context context-type="linenumber">65</context> + <context context-type="linenumber">64</context> </context-group> </trans-unit> <trans-unit id="5682285129543775369" datatype="html"> <source>Error deleting note: <x id="PH" equiv-text="e.toString()"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context> - <context context-type="linenumber">81</context> + <context context-type="linenumber">80</context> </context-group> </trans-unit> <trans-unit id="6316128875819022658" datatype="html"> @@ -4013,7 +4027,7 @@ <source>Automatic</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">100</context> + <context context-type="linenumber">103</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/data/matching-model.ts</context> @@ -4024,7 +4038,7 @@ <source>None</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">102</context> + <context context-type="linenumber">105</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/data/matching-model.ts</context> @@ -4035,42 +4049,35 @@ <source>Successfully created <x id="PH" equiv-text="this.typeName"/>.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">142</context> + <context context-type="linenumber">145</context> </context-group> </trans-unit> <trans-unit id="3928835053823658072" datatype="html"> <source>Error occurred while creating <x id="PH" equiv-text="this.typeName"/>.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">147</context> + <context context-type="linenumber">150</context> </context-group> </trans-unit> <trans-unit id="2541368547549828690" datatype="html"> <source>Successfully updated <x id="PH" equiv-text="this.typeName"/>.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">163</context> + <context context-type="linenumber">166</context> </context-group> </trans-unit> <trans-unit id="6442673774206210733" datatype="html"> <source>Error occurred while saving <x id="PH" equiv-text="this.typeName"/>.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">168</context> - </context-group> - </trans-unit> - <trans-unit id="4012132330507560812" datatype="html"> - <source>Do you really want to delete the <x id="PH" equiv-text="this.typeName"/>?</source> - <context-group purpose="location"> - <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">176</context> + <context context-type="linenumber">171</context> </context-group> </trans-unit> <trans-unit id="8371896857609524947" datatype="html"> <source>Associated documents will not be deleted.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">191</context> + <context context-type="linenumber">192</context> </context-group> </trans-unit> <trans-unit id="5467489005440577210" datatype="html"> @@ -4079,7 +4086,7 @@ )"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> - <context context-type="linenumber">204,206</context> + <context context-type="linenumber">205,207</context> </context-group> </trans-unit> <trans-unit id="1685061484835793745" datatype="html"> @@ -4464,231 +4471,231 @@ <source>Saved view "<x id="PH" equiv-text="savedView.name"/>" deleted.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">475</context> + <context context-type="linenumber">476</context> </context-group> </trans-unit> <trans-unit id="3891152409365583719" datatype="html"> <source>Settings saved</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">577</context> + <context context-type="linenumber">578</context> </context-group> </trans-unit> <trans-unit id="7217000812750597833" datatype="html"> <source>Settings were saved successfully.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">578</context> + <context context-type="linenumber">579</context> </context-group> </trans-unit> <trans-unit id="525012668859298131" datatype="html"> <source>Settings were saved successfully. Reload is required to apply some changes.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">582</context> + <context context-type="linenumber">583</context> </context-group> </trans-unit> <trans-unit id="8491974984518503778" datatype="html"> <source>Reload now</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">583</context> + <context context-type="linenumber">584</context> </context-group> </trans-unit> <trans-unit id="6839066544204061364" datatype="html"> <source>Use system language</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">602</context> + <context context-type="linenumber">603</context> </context-group> </trans-unit> <trans-unit id="7729897675462249787" datatype="html"> <source>Use date format of display language</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">609</context> + <context context-type="linenumber">610</context> </context-group> </trans-unit> <trans-unit id="5260584511980773458" datatype="html"> <source>Error while storing settings on server.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">629</context> + <context context-type="linenumber">630</context> </context-group> </trans-unit> <trans-unit id="4510369340305901516" datatype="html"> <source>Password has been changed, you will be logged out momentarily.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">659</context> + <context context-type="linenumber">662</context> </context-group> </trans-unit> <trans-unit id="2753185112875184719" datatype="html"> <source>Saved user "<x id="PH" equiv-text="newUser.username"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">666</context> + <context context-type="linenumber">669</context> </context-group> </trans-unit> <trans-unit id="3471101514724661554" datatype="html"> <source>Error saving user.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">678</context> + <context context-type="linenumber">681</context> </context-group> </trans-unit> <trans-unit id="5565868288871970148" datatype="html"> <source>Confirm delete user account</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">689</context> + <context context-type="linenumber">692</context> </context-group> </trans-unit> <trans-unit id="8133663925694885325" datatype="html"> <source>This operation will permanently delete this user account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">690</context> + <context context-type="linenumber">693</context> </context-group> </trans-unit> <trans-unit id="857903183180440990" datatype="html"> <source>Deleted user</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">699</context> + <context context-type="linenumber">702</context> </context-group> </trans-unit> <trans-unit id="1942566571910298572" datatype="html"> <source>Error deleting user.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">707</context> + <context context-type="linenumber">710</context> </context-group> </trans-unit> <trans-unit id="5766640174051730159" datatype="html"> <source>Saved group "<x id="PH" equiv-text="newGroup.name"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">726</context> + <context context-type="linenumber">731</context> </context-group> </trans-unit> <trans-unit id="8382042988405122578" datatype="html"> <source>Error saving group.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">736</context> + <context context-type="linenumber">741</context> </context-group> </trans-unit> <trans-unit id="6538873300613683004" datatype="html"> <source>Confirm delete user group</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">747</context> + <context context-type="linenumber">752</context> </context-group> </trans-unit> <trans-unit id="7710984639498518244" datatype="html"> <source>This operation will permanently delete this user group.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">748</context> + <context context-type="linenumber">753</context> </context-group> </trans-unit> <trans-unit id="6834066329827670963" datatype="html"> <source>Deleted group</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">757</context> + <context context-type="linenumber">762</context> </context-group> </trans-unit> <trans-unit id="8850738980935204840" datatype="html"> <source>Error deleting group.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">765</context> + <context context-type="linenumber">770</context> </context-group> </trans-unit> <trans-unit id="6327501535846658797" datatype="html"> <source>Saved account "<x id="PH" equiv-text="newMailAccount.name"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">789</context> + <context context-type="linenumber">796</context> </context-group> </trans-unit> <trans-unit id="8067594003836508139" datatype="html"> <source>Error saving account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">801</context> + <context context-type="linenumber">808</context> </context-group> </trans-unit> <trans-unit id="5641934153807844674" datatype="html"> <source>Confirm delete mail account</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">812</context> + <context context-type="linenumber">819</context> </context-group> </trans-unit> <trans-unit id="7176985344323395435" datatype="html"> <source>This operation will permanently delete this mail account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">813</context> + <context context-type="linenumber">820</context> </context-group> </trans-unit> <trans-unit id="4233826387148482123" datatype="html"> <source>Deleted mail account</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">822</context> + <context context-type="linenumber">829</context> </context-group> </trans-unit> <trans-unit id="6202503362522392111" datatype="html"> <source>Error deleting mail account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">831</context> + <context context-type="linenumber">838</context> </context-group> </trans-unit> <trans-unit id="123368655395433699" datatype="html"> <source>Saved rule "<x id="PH" equiv-text="newMailRule.name"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">850</context> + <context context-type="linenumber">859</context> </context-group> </trans-unit> <trans-unit id="8951124554918814321" datatype="html"> <source>Error saving rule.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">862</context> + <context context-type="linenumber">871</context> </context-group> </trans-unit> <trans-unit id="3896080636020672118" datatype="html"> <source>Confirm delete mail rule</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">873</context> + <context context-type="linenumber">882</context> </context-group> </trans-unit> <trans-unit id="2250372580580310337" datatype="html"> <source>This operation will permanently delete this mail rule.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">874</context> + <context context-type="linenumber">883</context> </context-group> </trans-unit> <trans-unit id="9077981247971516916" datatype="html"> <source>Deleted mail rule</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">883</context> + <context context-type="linenumber">892</context> </context-group> </trans-unit> <trans-unit id="2033194641751367552" datatype="html"> <source>Error deleting mail rule.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">892</context> + <context context-type="linenumber">901</context> </context-group> </trans-unit> <trans-unit id="5101757640976222639" datatype="html"> @@ -4786,29 +4793,29 @@ <context context-type="linenumber">103</context> </context-group> </trans-unit> - <trans-unit id="6798650225457993016" datatype="html"> - <source>Failed <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-1">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> + <trans-unit id="1830925490604698731" datatype="html"> + <source>Failed<x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context> <context context-type="linenumber">110</context> </context-group> </trans-unit> - <trans-unit id="2352193508676933865" datatype="html"> - <source>Complete <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> + <trans-unit id="6149567896789735123" datatype="html"> + <source>Complete<x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context> <context context-type="linenumber">116</context> </context-group> </trans-unit> - <trans-unit id="1697296301417588213" datatype="html"> - <source>Started <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> + <trans-unit id="7531670556122409927" datatype="html"> + <source>Started<x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context> <context context-type="linenumber">122</context> </context-group> </trans-unit> - <trans-unit id="6517676116023827583" datatype="html"> - <source>Queued <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> + <trans-unit id="7252570681759700719" datatype="html"> + <source>Queued<x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context> <context context-type="linenumber">128</context> @@ -5200,196 +5207,196 @@ <source>English (US)</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">159</context> + <context context-type="linenumber">163</context> </context-group> </trans-unit> <trans-unit id="6269202464699193298" datatype="html"> <source>Arabic</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">165</context> + <context context-type="linenumber">169</context> </context-group> </trans-unit> <trans-unit id="3098941349689899577" datatype="html"> <source>Belarusian</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">171</context> + <context context-type="linenumber">175</context> </context-group> </trans-unit> <trans-unit id="1001043467371963032" datatype="html"> <source>Catalan</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">177</context> + <context context-type="linenumber">181</context> </context-group> </trans-unit> <trans-unit id="2719780722934172508" datatype="html"> <source>Czech</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">183</context> + <context context-type="linenumber">187</context> </context-group> </trans-unit> <trans-unit id="2924289692679201020" datatype="html"> <source>Danish</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">189</context> + <context context-type="linenumber">193</context> </context-group> </trans-unit> <trans-unit id="1858110241312746425" datatype="html"> <source>German</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">195</context> + <context context-type="linenumber">199</context> </context-group> </trans-unit> <trans-unit id="6987083569809053351" datatype="html"> <source>English (GB)</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">201</context> + <context context-type="linenumber">205</context> </context-group> </trans-unit> <trans-unit id="5190825892106392539" datatype="html"> <source>Spanish</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">207</context> + <context context-type="linenumber">211</context> </context-group> </trans-unit> <trans-unit id="861663369293303028" datatype="html"> <source>Finnish</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">213</context> + <context context-type="linenumber">217</context> </context-group> </trans-unit> <trans-unit id="7633754075223722162" datatype="html"> <source>French</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">219</context> + <context context-type="linenumber">223</context> </context-group> </trans-unit> <trans-unit id="2935232983274991580" datatype="html"> <source>Italian</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">225</context> + <context context-type="linenumber">229</context> </context-group> </trans-unit> <trans-unit id="1334425850005897370" datatype="html"> <source>Luxembourgish</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">231</context> + <context context-type="linenumber">235</context> </context-group> </trans-unit> <trans-unit id="3071065188816255493" datatype="html"> <source>Dutch</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">237</context> + <context context-type="linenumber">241</context> </context-group> </trans-unit> <trans-unit id="792060551707690640" datatype="html"> <source>Polish</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">243</context> + <context context-type="linenumber">247</context> </context-group> </trans-unit> <trans-unit id="9184513005098760425" datatype="html"> <source>Portuguese (Brazil)</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">249</context> + <context context-type="linenumber">253</context> </context-group> </trans-unit> <trans-unit id="153799456510623899" datatype="html"> <source>Portuguese</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">255</context> + <context context-type="linenumber">259</context> </context-group> </trans-unit> <trans-unit id="8118856427047826368" datatype="html"> <source>Romanian</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">261</context> + <context context-type="linenumber">265</context> </context-group> </trans-unit> <trans-unit id="7137419789978325708" datatype="html"> <source>Russian</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">267</context> + <context context-type="linenumber">271</context> </context-group> </trans-unit> <trans-unit id="4287008301409320881" datatype="html"> <source>Slovenian</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">273</context> + <context context-type="linenumber">277</context> </context-group> </trans-unit> <trans-unit id="8608389829607915090" datatype="html"> <source>Serbian</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">279</context> + <context context-type="linenumber">283</context> </context-group> </trans-unit> <trans-unit id="499386805970351976" datatype="html"> <source>Swedish</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">285</context> + <context context-type="linenumber">289</context> </context-group> </trans-unit> <trans-unit id="5682359291233237791" datatype="html"> <source>Turkish</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">291</context> + <context context-type="linenumber">295</context> </context-group> </trans-unit> <trans-unit id="4689443708886954687" datatype="html"> <source>Chinese Simplified</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">297</context> + <context context-type="linenumber">301</context> </context-group> </trans-unit> <trans-unit id="4912706592792948707" datatype="html"> <source>ISO 8601</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">314</context> + <context context-type="linenumber">318</context> </context-group> </trans-unit> <trans-unit id="313643372755303297" datatype="html"> <source>Successfully completed one-time migratration of settings to the database!</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">425</context> + <context context-type="linenumber">429</context> </context-group> </trans-unit> <trans-unit id="5558341108007064934" datatype="html"> <source>Unable to migrate settings to the database, please try saving manually.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">426</context> + <context context-type="linenumber">430</context> </context-group> </trans-unit> <trans-unit id="1168781785897678748" datatype="html"> <source>You can restart the tour from the settings page.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/settings.service.ts</context> - <context context-type="linenumber">500</context> + <context context-type="linenumber">504</context> </context-group> </trans-unit> <trans-unit id="5037437391296624618" datatype="html"> @@ -5403,28 +5410,28 @@ <source>Connecting...</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/upload-documents.service.ts</context> - <context context-type="linenumber">33</context> + <context context-type="linenumber">31</context> </context-group> </trans-unit> <trans-unit id="1245343823699368872" datatype="html"> <source>Uploading...</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/upload-documents.service.ts</context> - <context context-type="linenumber">45</context> + <context context-type="linenumber">43</context> </context-group> </trans-unit> <trans-unit id="7446520539098045935" datatype="html"> <source>Upload complete, waiting...</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/upload-documents.service.ts</context> - <context context-type="linenumber">48</context> + <context context-type="linenumber">46</context> </context-group> </trans-unit> <trans-unit id="1405142710727603568" datatype="html"> <source>HTTP error: <x id="PH" equiv-text="error.status"/> <x id="PH_1" equiv-text="error.statusText"/></source> <context-group purpose="location"> <context context-type="sourcefile">src/app/services/upload-documents.service.ts</context> - <context context-type="linenumber">64</context> + <context context-type="linenumber">62</context> </context-group> </trans-unit> </body> diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 1fa9ecb20..6b42fade8 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -191,9 +191,12 @@ <div [ngbNavOutlet]="nav" class="mt-2"></div> <ng-container> - <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button> - <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & next</button> - <button type="submit" class="btn btn-primary" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button> + <button type="button" class="btn btn-outline-secondary me-2" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button> + <ng-container *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> + <button *ngIf="hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & next</button> + <button *ngIf="!hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & close</button> + <button type="submit" class="btn btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button> + </ng-container> </ng-container> </form> </div> 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 index 0e6a99d89..6ca99803c 100644 --- 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 @@ -372,12 +372,25 @@ describe('DocumentDetailComponent', () => { const updateSpy = jest.spyOn(documentService, 'update') const toastSpy = jest.spyOn(toastService, 'showInfo') updateSpy.mockImplementation((o) => of(doc)) - component.save() + component.save(true) expect(updateSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.') }) + it('should support save without 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).not.toHaveBeenCalled() + expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.') + }) + it('should show toast error on save if error occurs', () => { currentUserHasObjectPermissions = true initNormally() @@ -406,7 +419,7 @@ describe('DocumentDetailComponent', () => { updateSpy.mockImplementation(() => throwError(() => new Error('failed to save')) ) - component.save() + component.save(true) expect(updateSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.') @@ -430,7 +443,7 @@ describe('DocumentDetailComponent', () => { expect }) - it('should show toast error on saveAll if error occurs', () => { + it('should show toast error on save & next if error occurs', () => { currentUserHasObjectPermissions = true initNormally() component.title = 'Foo Bar' @@ -448,6 +461,39 @@ describe('DocumentDetailComponent', () => { ) }) + it('should show save button and save & close or save & next', () => { + const nextSpy = jest.spyOn(component, 'hasNext') + nextSpy.mockReturnValueOnce(false) + fixture.detectChanges() + expect( + fixture.debugElement + .queryAll(By.css('button')) + .find((b) => b.nativeElement.textContent === 'Save') + ).not.toBeUndefined() + expect( + fixture.debugElement + .queryAll(By.css('button')) + .find((b) => b.nativeElement.textContent === 'Save & close') + ).not.toBeUndefined() + expect( + fixture.debugElement + .queryAll(By.css('button')) + .find((b) => b.nativeElement.textContent === 'Save & next') + ).toBeUndefined() + nextSpy.mockReturnValue(true) + fixture.detectChanges() + expect( + fixture.debugElement + .queryAll(By.css('button')) + .find((b) => b.nativeElement.textContent === 'Save & close') + ).toBeUndefined() + expect( + fixture.debugElement + .queryAll(By.css('button')) + .find((b) => b.nativeElement.textContent === 'Save & next') + ).not.toBeUndefined() + }) + it('should allow close and navigate to documents by default', () => { initNormally() const navigateSpy = jest.spyOn(router, 'navigate') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 654f68a50..cd402817a 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -518,7 +518,7 @@ export class DocumentDetailComponent }) } - save() { + save(close: boolean = false) { this.networkActive = true this.documentsService .update(this.document) @@ -527,7 +527,7 @@ export class DocumentDetailComponent next: () => { this.store.next(this.documentForm.value) this.toastService.showInfo($localize`Document saved successfully.`) - this.close() + close && this.close() this.networkActive = false this.error = null }, @@ -535,7 +535,7 @@ export class DocumentDetailComponent this.networkActive = false if (!this.userCanEdit) { this.toastService.showInfo($localize`Document saved successfully.`) - this.close() + close && this.close() } else { this.error = error.error this.toastService.showError( From 4782b4da0701e0e01f4e9d5a08d77279b236798b Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Sun, 18 Jun 2023 07:04:53 -0700 Subject: [PATCH 09/12] Adds better error handling/checking around getting content of a document via Tika Signed-off-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com> --- Pipfile.lock | 6 ++--- src/paperless_mail/parsers.py | 9 +++++-- src/paperless_tika/parsers.py | 12 ++++++++-- src/paperless_tika/tests/samples/sample.doc | Bin 0 -> 23552 bytes src/paperless_tika/tests/test_live_tika.py | 25 ++++++++++++++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 src/paperless_tika/tests/samples/sample.doc diff --git a/Pipfile.lock b/Pipfile.lock index d948729ef..6bf949a7f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1746,11 +1746,11 @@ }, "tika-client": { "hashes": [ - "sha256:43b53816b3783c9c77e16df314cad5ad66ab606391c26ad4bc94a784d473a156", - "sha256:e1ef3447b4307059e4a836e3786088498637323733f83a2f807b77f998d77610" + "sha256:29b702d64bbbaa324a75f99062efb3253239762cbf0a3419a47549c2de9379d0", + "sha256:63a93593068dc0da07108dc47c12cd3ff00f07403cff72c86bea6a89abafbf6d" ], "index": "pypi", - "version": "==0.0.3" + "version": "==0.1.1" }, "tornado": { "hashes": [ diff --git a/src/paperless_mail/parsers.py b/src/paperless_mail/parsers.py index 3ec3e64a0..f7daa758e 100644 --- a/src/paperless_mail/parsers.py +++ b/src/paperless_mail/parsers.py @@ -13,6 +13,7 @@ from humanfriendly import format_size from imap_tools import MailAttachment from imap_tools import MailMessage from tika_client import TikaClient +from tika_client.data_models import TikaKey from documents.parsers import DocumentParser from documents.parsers import ParseError @@ -172,8 +173,12 @@ class MailDocumentParser(DocumentParser): with TikaClient(tika_url=self.tika_server) as client: parsed = client.tika.as_text.from_buffer(html, "text/html") - if "X-TIKA:content" in parsed.data: - return parsed.data["X-TIKA:content"].strip() + if hasattr(parsed, "content") and parsed.content is not None: + return parsed.content.strip() + elif TikaKey.Content in parsed.data: + # May not be a completely handled type, but + # the Tika response may still include content + return parsed.data[TikaKey.Content].strip() return "" except Exception as err: raise ParseError( diff --git a/src/paperless_tika/parsers.py b/src/paperless_tika/parsers.py index 10447ff53..8b476bfd8 100644 --- a/src/paperless_tika/parsers.py +++ b/src/paperless_tika/parsers.py @@ -4,6 +4,7 @@ from pathlib import Path import httpx from django.conf import settings from tika_client import TikaClient +from tika_client.data_models import TikaKey from documents.parsers import DocumentParser from documents.parsers import ParseError @@ -58,8 +59,15 @@ class TikaDocumentParser(DocumentParser): f"{settings.TIKA_ENDPOINT}: {err}", ) from err - self.text = parsed.content.strip() - self.date = parsed.metadata.created + self.text = None + if hasattr(parsed, "content") and parsed.content is not None: + self.text = parsed.content.strip() + elif TikaKey.Content in parsed.data: + # May not be a completely handled type, but + # the Tika response may still include content + self.text = parsed.data[TikaKey.Content].strip() + + self.date = parsed.created self.archive_path = self.convert_to_pdf(document_path, file_name) def convert_to_pdf(self, document_path, file_name): diff --git a/src/paperless_tika/tests/samples/sample.doc b/src/paperless_tika/tests/samples/sample.doc new file mode 100644 index 0000000000000000000000000000000000000000..72178a7beccd5c551dc4f14b778a787e08952efd GIT binary patch literal 23552 zcmeHP3tWs@|35QR(?$0?Qd5#B-ESmCqD?oI=na`_y67^g5jIwY-LTZUg%*)@TWu@3 z6|t=)*Fup{N)as)o7OV_-|v|oW+Uvr@BjV0?`uwd=bZC9=X}pOzjL1RoZC#rMb-M; zAN$-U-OLEWBCmvUge@j#fO`<_S0Tg{+$eh`6beyn0syDP{1<7Ub^8gTPvoQsX;Ul3 zMkEAU7K|(*F#shJHa%>5{-FHV4NrwqA97M!#6gLWb8anBh?qA)?{o=;%CF@;x#<W8 zDMy8z$dC3mZISzZwg#OXY-|Md9_&i@E?WV@%OD(WIy}8MA$icPFLtH-kiA<6b0MU= z%>*7M$fdwW^D(veU`kOPqLW<Ns0z;6QSHIbm3q4hxL+b#I5^3d^U8+pYI%}3jG zz3DjUuLX}Yq|*ZXOjo_T+sKbys5?)0Kk7%^&^+R@k8xvuXg<XEclkx^Z-3H!w4Uhx zMlQ9~eVJQ>10!W<`H?QY(CO&#haks(8vy#S1RMJw8akef-uwNc(0~4u&WC2A+Y8Hq z{c?Ago@tvdAMK}YI(<(z9k07h_ea|9DLpMe(^VeY-;>=vAKFjrnfCW&)4X&$cDFwi z-aY=m%1^gfPv!1OPM7zu+CA~%*t{MF&=?pLhC#2FCTj30CnW`M4~h9t*Fa!&Ofm=l zxf}s6S-^?lhtG`XB?!zo$=noP1SckeBZ%g4_;C@uB#t?Raw7Rj@mv8~oQeFTh}S8S zIVNE<1)T6C9#<erIF`?kisNxcC&i@j1}XlhE8<fvA3UYeU30xZo`0%J_;l*c)`~Xt zi6sQ=PPN@ILG5b$m!FObBsCRj0B5KSJECgI(Dh>w{elV$PC>o1K>>$h2<V^5VHg1h z6cD0aP{GszC}dVJM<>3X*-8}o->(5fwklDBU7XnrWg-K^f#^>PhXs-i*z^I|!v@iZ zkOHgO51}WH5s<d0u>TMFPN*-<i>qAP-IIOjBjR*V8x2p82cWzBQ}icxwXZewBV5q^ z!Xl&^&<ywuz=oYEEr2n=9xw(F1c(Hr0G0sO0(we^ye5#xHPH7xz~ANh)YCbF?j`}A z17ty;I)EU+7l2H_7C-^uIG_TM2B-(L0bnbcr~o(sOMojt687)Y%e%%EBgo#@lS%vl z;{j6vp&ksWcn>+L1P>|MWUvKbLp0(^$h5~oCfEwEH(1%khww=fi6>kV2XQc>T7(9J zL3D|mrxwM<1snKC7au`B4|vxw2$Oic=E@{;;Nt;L1c@ODB#Ll|6|kv8JZU2a+lV1! zMAY*#85()BF&{Ti6T$^}0ShvD5e3*OMe{BuYmlo$EHS2N5mPT>C!6Nx$pJDBA%W0I zkuC7OY&H}dOPtLW*!*w@57<TxR(A8$1wvht813ffM*N8s#p?fFR$GcyN}N?k#Hvat zN*$oo0m{h8NCKSFAHiuyaY~DGwh^kNU7T9LsYP=J!a2`}Yj00+vfsg}LF{E0P(}R{ zEyO2eG#SZxBFqI_p=%RB1@@0eLyJd3o6m&aBY+n719uW2iuz0>(I6WGJuCr2(fC8( zXcrcOO|w|C6`-}auty#TT%2zGQI}}r>%+DhDkjg06FH<o!bLHY^a4&363cliJb~v{ zT_v+*%TPQV=v6T!naZ0<oemJ<N=8sRa(&%%w?In-LOcOg0_<nX;<|xOkvCk_V{Jj% zwop<}iXrSZLjm;X0MJ1gaESA$bmwtpz#go9R}a&B>&fMM>UA}x-#C_(2DBzVj|QaW zmU1Z`g6P61tPXaF8$Uq++dH9wa}s&UQ_N?_$9<o5)~C!;#l6GhkMyP?o;mwmWe2n@ zJhkG~*15+|uQ$>vzqW73gcluNmjXxa<Y>j2)!1}wa%xOEsxMPimYuW1^M@y+OpPA& z9Xn-vor7=9-k^*HeRPI%w<~8qJ#wtX#No@d&oVanZ%=JbjPly&ydpD6KQr&r?~cr~ zb}7bbFVmFH&m4b4=Rro6V`I77)h5<~f_{^nA2xMn8#QF^KjLEV^S!5=Y<l7GZ?3fN z>>N|BHOl<V;epT0^Gq{x&j<e$*x9?_-c+7lrQOb3oF~1OtCbZfWQ1Of;`AQUT<Wwb zqqVj=IIiU8ihV2Q+D|>_b3&*w?U~Cx^~>kxhs+0!2!$;2XMJt2-f{Oh=*gZzs2&oU z#EVO|FvrhDD=$tjvsC!<(c+`&mGj15D$^N|VzE8b&0^a;a@%Bnv|-=cz7J}BPqF2T zWf$c=>e%J{Dt}q$?bUZVS{rt=%NpEV&PLx(aW0*|uzYdejsdbEvlEwWORX&mE%B?| zHvGI*-|Zd;EZ3+he^ujK;-5b%qOx4&lBavEZJ5#g9a%F%uj*%S>RoT|b@`c=|5b-+ z`73vp3k%CE&u#KOG5-g%CU>a>ht*y&cfxXaS~Lb6&D_b`eMPUL=ul_%OQ_1*6^)&< zRx2GUx)h)bfK9%5o`u+_H#dPB#Y@5-qHxYzzKo;ddV5~(tjrpR@M079uzhFqi+>|- zIekW++Hz@g?bW8nbcYLd8dV#|DF&P6$S5x5oj3pLVB1`?9esCR_s~6Q7I*uUW^>DI zo$~!(tJi&YI(huO{#BWa_ZHpcR2P|689Xw(IrXs7bmwJbC*Hf<F{r`)fvK@P)A8Mf zH~7caMFui(8U^D%&e6q+EvkYQ7P~drm*3Z2qqP6yeq&NCepDamy>-}x)@zFUW42uz za(_mN({DaD#f2Ms4}7dxG}g#y^hn*EKYz_)4lYt#_auht?w9w4^sRHP`Lh~ktXfSp zZNIE`dXdV%_2Q^Yd(;n;ojrAiaYp%XvQPg#wTNWd-cj`)d%ixUG&RP~)&I8bPs?}Y z_4fKoAwQwy#Rbih{IG9Sq@L`WeCj+WXmCjAq)Y>a@J_vp9`cWtOg5TyVw=v8(M<h? zw-yaCtDbJ!OYXavH2<8m>mg+ZigNd3*PIxWTT)hUHNjtbk=N7x(;GZ=;`WDLl^vQD zZ(PBMJWrZ4o^v<r?jlxZnkCU}yLN4}xXHoC9~@Q*NRBW~n$o`RY!kEc)z<X^%h^qN z&l19aeG;^*j%05*Idj;zDLba#w;{Sq>UR}w-(6TaW$!`xZ#6St4&75cB_!W&@?3Y} zrRC>i_Xfqz8FKLCU^~u>LaX%9y?br#wfmJYg8z7;aIErr+`);u=sBZsEOk!&_S+4L zCXu!`H;;UfmQsB9H#O@!akafdrJqk9@}=+iOhsNw%b+F0C$uLGIni<G9@CZ8JIL03 z`ZrkyN(Renvnv;D`J7qeli2iiKl!F}nVWi*`&D?^r7K%2Xj)ZuJgJ#wvDta#sgBKh zb;8HbPoy`#aGB)3;)=b`*a^F|>*oG`YTL?+N503tc-)rkGBxON>0IICi!Sq;Wkz*+ zouBjSWMk;&5nsG&8dy5-MFp!)W47Poj+jwKM?O=k)bl-VRkk#CXz#DuKYJSPwMT7Z zi${aTgW%!6I}J{$a2S})RnB*x%zh*rtv{l^=&|#u`l)8W3|@J#vF4fMj5Md5LM`p7 zTKNwS54P<%)pFY9u+GF8sXF@4n-dzZWbmVMM%LwxSo3SPW8_2YoWa8l2aWeAZG5q4 z_VXj-c9lI)Qj}gdcW1?vrT@tOd{E2f1*^ORa^p9+PBtFFGPtGW;55r`na$>_A$N|u zkD4&8+3J{Ywp*j!@F2&>yILC;O^_>HFrn3EpMxq_^J<p7p|j!gZ9b#(o|fNGtLG26 zcQtqB^Ri=Q#u>{rmOgwTxOF^dVpho9(teFb?c7w`ulHQ*Xd01cGNyQPXT8&ny(%vi zW*l$1kyw}evs_2Z&3gNOLyf)FN0;{Q<!knX>ccyH{!dG?vx8XPH(RRYyhE!Fso$$T z+^Bu^Q0{~g!uwgL9y%29_)NKD8)`;W?!0^3=fGWiZPj`0#qC;pJBp>`!%{Lnug$5m zzgzE8mvgtO{p7(rG1^Pmdk=c0Y>Pi|v;4-Cj!f(70QE5m9)*wB7W_7Piq4g!=AS=1 z6EI1u%y&w;<%0CSa&Zxv_4nO&H%wh_b8Y`s*4_xC<f{G|*5<bhqL&;!Vy9>S>WrS< zx@QJIrzmAqDXna6`$o_FLZ9t@!mCc}MlZ`)yMJM!&jG>EiSoyeUWk|(e`S5tp1zm8 z0tAt!Q>ryKx()T4dW4-mU}M3qkp-H}wG->l{jg(6nNp<DrIK15O|I4W=c%Jsr^ffo zm@zu;tDM{N8)vE)8!nFg<MP8A-J$v$2gJHwZyA%lKYO!Fl+j^7%K^Qme1p`-?I}9x zFSw>qy;gf2OQYfY-U_#~d%3JqSZMsrUZqg=hUQ|=Yu`H;X?bV)I8SYln$R!7yjf#; zz%T1Fr}sL0?U#y@Gj1pSomUmjpK&;M)vsZ?ZMLflgMZvFKj_I1!HnD#w{c6{)OJrV zFzvs==|%ReQ6qvzKW(_DqIhjmD_7PfareuNor`Ma11f%J<X79)Hm|7-Y4-^@T4C+z zv?5rmu_WaFp7mQB%x*kgb&1^`Xr(;2{dz)<Q>p2Z28%OmruPwEoLqU9F<H;deHhc= zMyvdC!FF4gdEmL+UOX$sVrKQc%TAn-`p>ndvN{t><6ddsaTW4oFWk4}DMq;#jUDUZ zH_0c^Otxn6frx#NG<eVEw){9aIRA7+;K|ABg9~Nuq!k={xWM#?jEu>WK!=+~d=`6_ zweZH>l7;O4N5}4}-gvqE%=XLG$6b!j`0=EI&$rjc-TPz6a|>^?i5)HXqnf^+{o|OR zew7yA73Yl^uX&;2;DNQi&o&t6@EG~u7aa&cKfC16WF49DInQ+1XJ2};C%SqFSr7Cz z4GQ&5pDw(9H?<-}b#7|G9|ouIk2|twB)f$jGhcC9UARoVb<Bjd*XQ2d7Hq2Zpx3RP zYuh(*LPAp3&fdJLHA%h6=kApT_iw(nX-$kj)$FgB<s7`#($?&Pfw5(?>BMJl8u~ZV zUHK>02(xQ^1FX{r2FTRpTh`dsR~iY+^QI)mSGU+KAG1%eQbG0W6-(R4mM<D&@M2Eh zHN(F9wJv7_$(wg7DdtaGadm4-k=bHxUv2XN=gw8^8^=vv*Bn6fPd)i%@5+?4gWlDj z?_IWM+S2Xw7w@ytd9>qP>XV_Syx+DwKjV6o&AWfEYITzrV@_d)%w`AaCEF%md>T4Q z5cl22)i<BROz`a)fnsIm<^%9D?F;jTf!$uJqG=HIniZQDbWMxA<$to&Sk$p_mMout z`G`uE@y)L)_FiEvx?`tk?_xB*^;gAwGg<R5xyRPW6tDZXTs_?SmR0obKC;7F*38&& zBK}b83RizEpNtx(h=OSgSMVKv)r`s-XX0&a<Zohbm7SzC&~sHz#nQmew0y^HD}(MB zZu@TJ>Ea5Hj+yheJl@Edd(!A=m`Z9D>Alu(gRt<WW1oyD3w^CAuf~r|u30Q?vh|m9 z_5C;Q_L4dia^%R*bx<);)fvnoux9INo($2%%sXbye&ha3(0$_gCbcE#Q*0X9Jz@8W z*qemCVTStF%tlQ8-I==2irz3)zl({QBX#HK-U;4tn9BdTH1FvWe&eKJ(+w{INRug0 zdt+Q{k>M^dC*!2>l9FTi3C@OA=9Y#WUP3rOA|@fq*>FPOm?4gaoMZtvA%YvnPvAKl z&fz5+x{OeiAI=>X9+%|JP2>PULh>-KvthJAkT}f3A~_spt=wdDej+ac!bB(2;7y9M zh)Ckj0^;~M3u{YDdy9B(OoAaNEXvB+Fiv1)$Pvs2S41qh!lJBEW{om%MZ|)O8y*g8 zB#6=Fpd}D4lDv*%LrZMvIJUIJmX_Gj5<6OAPfP3#Ini-339(Qkv<%@?J<dZUpk0O* zBT##Iwax%<<AI?rkihy5ujfglpv#R)hYNOXjwmDSHr!5S!qzCXJ}f)m0R65+?=fD| z5?zrKlC-om(t-E3lnJx#8wgM#vTeXCN(dy%gw=+(laz^6n+YYCYeRzWhDqe9bg=P3 z6%ON%f=Fszat6q;8UdtN8(wTGWvD=2XR&-l2%oY<iiMZ{Tv)hIM2Kn|CZK}Bjt0a6 zSa4Q>M%)j}dd!qs`8Hu77_6#xcprjCo_O4e233h1XpmRvO44Y4N(j<F`%%iID~rih zm{4X{)E-8ZAroMmA{M|Wv)~h@s5;t+Vu^Ikq%{t!a@vhm8LO8GR7@tDB`qb*WJw9l zNqxv{avW8GhRO(R+-``6?T%!E8y-B!j{{pGd}<pm0mAKo2R6H;8BB%@n<*{Q61L_W zlZMU!G6&LxK}y`bvoWV~@5Pp4qUkaBk+3-v16wYb#z`rYu|$T+U=tPt4XU4zl_t0# zL&KmN19QR><0c1|Z&^ryFCh-1{FJ3JGnOd7RHL_R>qg}j1LeegA)QD|QqrI$Hl|~f zdZ7`){)H_~He+*u!E{(VQE3So`M1(>h&S*Es7_`GwXu>-(;;oRxs3-na6FNDKn8(s zQO0<^^&gZE{ZfR5YQ<+#Oe%^ASL#@VdVpv@M3R_~)&PgWVeBXS$$|xHI4X%591;-! zjG;P{h+tH@Dz=j>`2+VN-fd)E{V+hT4emF~8iD%`j6*O=&+iG;4Gf5bo6~O_102P= z85P*1p<b$#9QMHpf;L;I*MMlt3S~FC0)#>}I!c$VM8e_e<-^k$0WRELO@UD)33_=b zv4wH@eWBf`B;Nl84ZMOEk-TX1{8M$Xv)3=){G`)2T4hg`EHNEeSc6ZRECj%1Pinx! z#|%fmM3e*40Qkbn24Kg_1HiPA{0zV}6#(35$L@(UnR@^n9NPdYfad_*rABgm1o1J# zIRbL~;I%_|@1P&!hR5+|MuhVdU_vPfg##3nLv-ro2;DjW@0jo;elkB&!10ZYj0xva z7ZyS83ZRWEI?VKr<Be@`H@?nL2WVp%(Z-=2ZHbX+K%xPO1|%AgXh5O?i3TJZkZ3@n z0f`1A8jxt<zfA-0TmLhw&R6A__f=W74%Yu>o%xnfIb5@gum5pvoCv@jsJQ^#q*??} z1uO^Pm*`gm@C+yifNS?W0Iu`D2jF(`egN(ZlmL*s93Z~_$8|j#@ijTUHjnO-BQtf* z1pc0EJ;)r>A&3HaFcaJ?z(NPc2;z9JvjBm_NHiePfJ6fl4M;Q~(SSq)5)DW+AklzC z0}>5LH1NNp0bC2?G8os^xX#9PJT8avTpxee5zq7S3?J9jxHiXiI-cF*x*yl_xX#D3 zeq7IM0PvUUa7~ZveoT))X4VG)KWayP(Fnh)iA!#M0EQcYZ3yTOFait!7y||ZF#IK8 zaF-kX2#NXEX#g(|^5Oaqhq%LKAOTzjngiPYc%wz68FXo|53%L2HxY82`h>4=ikq%q z#xnz1T9@2P>^q#|Q9stLM3_|m*l)2MACnI6lhD7OHW6x{1ec*kgB=dnqVRGh{hiZ~ z;pzkV<1<9-;A7HIaq!^*BTZe6jE8*j`>%7zXOMd&RVI|+w?grfR?o%+@<)y1v-`32 z$Gb7q!wZ)5TQ$e4njc>t$1eT<mHKnU@@K$hO}xIENPOYK=5!4COGXVwFFNm!tv_av z^p|pZf8@WD|N9yLTIfHMKlWY8m@2NpPs`1K)@Q*B9&6ow?B+n<cyou2`nA8I4nyDz q6*ipFUB44Z%?-Q*FbqW0pn`65I^5imJRQ1k#$W5tlGy(~4g4R&0*@X5 literal 0 HcmV?d00001 diff --git a/src/paperless_tika/tests/test_live_tika.py b/src/paperless_tika/tests/test_live_tika.py index 9a83614b1..f4c8e0134 100644 --- a/src/paperless_tika/tests/test_live_tika.py +++ b/src/paperless_tika/tests/test_live_tika.py @@ -118,3 +118,28 @@ class TestTikaParserAgainstServer(TestCase): self.assertTrue(b"PDF-" in f.read()[:10]) # self.assertEqual(self.parser.date, datetime.datetime(2022, 9, 14)) + + def test_basic_parse_doc(self): + """ + GIVEN: + - An input DOC format document + WHEN: + - The document is parsed + THEN: + - Document content is correct + - Document date is correct + """ + test_file = self.SAMPLE_DIR / "sample.doc" + + self.try_parse_with_wait( + test_file, + "application/msword", + ) + + self.assertIn( + "his is a test document, saved in the older .doc format", + self.parser.text, + ) + self.assertIsNotNone(self.parser.archive_path) + with open(self.parser.archive_path, "rb") as f: + self.assertTrue(b"PDF-" in f.read()[:10]) From 865efb7752af7c91acab6ddf8b1386ad03f0ed83 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Sun, 18 Jun 2023 09:40:14 -0700 Subject: [PATCH 10/12] Sets the retention days for all uploaded artifacts to be 7 days after upload --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9305c5154..34375399e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,7 @@ jobs: with: name: documentation path: site/ + retention-days: 7 documentation-deploy: name: "Deploy Documentation" @@ -208,6 +209,7 @@ jobs: with: name: jest-coverage-report path: src-ui/coverage + retention-days: 7 - name: Upload frontend coverage to Codecov if: always() @@ -227,6 +229,7 @@ jobs: with: name: playwright-report path: src-ui/playwright-report + retention-days: 7 build-docker-image: name: Build Docker image for ${{ github.ref_name }} @@ -347,6 +350,7 @@ jobs: with: name: frontend-compiled path: src/documents/static/frontend/ + retention-days: 7 build-release: needs: @@ -455,6 +459,7 @@ jobs: with: name: release path: dist/paperless-ngx.tar.xz + retention-days: 7 publish-release: runs-on: ubuntu-22.04 From 05188aed6db2b0fe9a9f886939ff164478ec0c08 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Sun, 18 Jun 2023 09:46:49 -0700 Subject: [PATCH 11/12] Bumps our locked pipenv version for CI and Docker image builds --- .github/workflows/ci.yml | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34375399e..9809479fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ on: env: # This is the version of pipenv all the steps will use # If changing this, change Dockerfile - DEFAULT_PIP_ENV_VERSION: "2023.4.20" + DEFAULT_PIP_ENV_VERSION: "2023.6.12" # This is the default version of Python to use in most steps # If changing this, change Dockerfile DEFAULT_PYTHON_VERSION: "3.9" diff --git a/Dockerfile b/Dockerfile index 43471f6a1..23fd384fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ COPY Pipfile* ./ RUN set -eux \ && echo "Installing pipenv" \ - && python3 -m pip install --no-cache-dir --upgrade pipenv==2023.4.20 \ + && python3 -m pip install --no-cache-dir --upgrade pipenv==2023.6.12 \ && echo "Generating requirement.txt" \ && pipenv requirements > requirements.txt From 74fe7c586ba581f547cde687e7d8e52b7e2d0354 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 19 Jun 2023 08:21:10 -0700 Subject: [PATCH 12/12] Updates the httpx timeout to be 30s for all operations --- src/paperless_mail/parsers.py | 9 +++------ src/paperless_tika/parsers.py | 8 +++++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/paperless_mail/parsers.py b/src/paperless_mail/parsers.py index f7daa758e..dec2b4754 100644 --- a/src/paperless_mail/parsers.py +++ b/src/paperless_mail/parsers.py @@ -218,7 +218,7 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post(url_merge, files=pdf_collection) + response = httpx.post(url_merge, files=pdf_collection, timeout=30.0) response.raise_for_status() # ensure we notice bad responses archive_path.write_bytes(response.content) @@ -336,6 +336,7 @@ class MailDocumentParser(DocumentParser): files=files, headers=headers, data=data, + timeout=30.0, ) response.raise_for_status() # ensure we notice bad responses except Exception as err: @@ -414,11 +415,7 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post( - url, - files=files, - data=data, - ) + response = httpx.post(url, files=files, data=data, timeout=30.0) response.raise_for_status() # ensure we notice bad responses except Exception as err: raise ParseError(f"Error while converting document to PDF: {err}") from err diff --git a/src/paperless_tika/parsers.py b/src/paperless_tika/parsers.py index 8b476bfd8..876696633 100644 --- a/src/paperless_tika/parsers.py +++ b/src/paperless_tika/parsers.py @@ -96,7 +96,13 @@ class TikaDocumentParser(DocumentParser): data["pdfFormat"] = "PDF/A-3b" try: - response = httpx.post(url, files=files, headers=headers, data=data) + response = httpx.post( + url, + files=files, + headers=headers, + data=data, + timeout=30.0, + ) response.raise_for_status() # ensure we notice bad responses except Exception as err: raise ParseError(