diff --git a/.eslintignore b/.eslintignore index d5a49554f..43d7ca18b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,7 +1,7 @@ node_modules/ coverage/ dist/ -src/assets/old-client/ +src/assets/ baw-client/ decorate-angular-cli.js e2e/protractor.conf.js diff --git a/.eslintrc.json b/.eslintrc.json index 3202b9785..1b7977121 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,45 +1,45 @@ { - "root": true, "ignorePatterns": ["projects/**/*"], "overrides": [ { - "files": ["*.ts"], - "parserOptions": { - "project": ["tsconfig.json", "tsconfig.e2e.json"], - "createDefaultProgram": true - }, "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/ng-cli-compat", "plugin:@angular-eslint/ng-cli-compat--formatting-add-on", - "plugin:@angular-eslint/template/process-inline-templates" + "plugin:@angular-eslint/template/process-inline-templates", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript" + ], + "files": ["*.ts"], + "parserOptions": { + "createDefaultProgram": true, + "project": ["tsconfig.json", "tsconfig.e2e.json"] + }, + "plugins": [ + "eslint-plugin-rxjs", + "rxjs-angular", + "@typescript-eslint", + "import" ], - "plugins": ["eslint-plugin-rxjs", "rxjs-angular"], "rules": { - "no-implicit-globals": "error", - "no-underscore-dangle": "off", - "@typescript-eslint/member-ordering": "off", - "no-shadow": "off", - "@typescript-eslint/no-shadow": ["error"], - "quotes": ["error", "double", { "avoidEscape": true }], - "@typescript-eslint/quotes": [ - "error", - "double", - { "avoidEscape": true } - ], + "import/no-unresolved": "off", + "import/no-deprecated": "error", "@angular-eslint/component-selector": [ "error", { - "type": "element", "prefix": ["baw"], - "style": "kebab-case" + "style": "kebab-case", + "type": "element" } ], "@angular-eslint/directive-selector": [ "error", { - "type": "attribute", "prefix": ["baw"], - "style": "camelCase" + "style": "camelCase", + "type": "attribute" } ], "@angular-eslint/use-component-view-encapsulation": "error", @@ -52,6 +52,7 @@ "accessibility": "explicit" } ], + "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/member-delimiter-style": [ "off", { @@ -65,26 +66,57 @@ } } ], + "@typescript-eslint/member-ordering": "off", + "@typescript-eslint/no-shadow": ["error"], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ], + "@typescript-eslint/quotes": [ + "error", + "double", + { + "avoidEscape": true + } + ], "@typescript-eslint/semi": ["off", null], "arrow-parens": ["off", "always"], "brace-style": ["error", "1tbs"], "eqeqeq": ["error", "always"], "guard-for-in": "off", "import/order": "error", + "no-implicit-globals": "error", "no-multiple-empty-lines": "error", + "no-shadow": "off", + "no-underscore-dangle": "off", "prefer-arrow/prefer-arrow-functions": "off", + "quotes": [ + "error", + "double", + { + "avoidEscape": true + } + ], + "rxjs-angular/prefer-takeuntil": [ + "error", + { + "checkDestroy": false + } + ], "rxjs/no-create": "error", "rxjs/no-internal": "error", "rxjs/no-nested-subscribe": "error", "rxjs/no-unsafe-takeuntil": "error", - "rxjs-angular/prefer-takeuntil": ["error", { "checkDestroy": false }], "space-before-function-paren": "off" } }, { - "files": ["*.html"], "extends": ["plugin:@angular-eslint/template/recommended"], + "files": ["*.html"], "rules": {} } - ] + ], + "root": true } diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 51b16fc9a..bac8b5f27 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -194,7 +194,7 @@ jobs: # Do not run on dependabot requests because of https://github.com/EnricoMi/publish-unit-test-result-action/issues/95 if: > github.event_name == 'pull_request' && - !(startsWith(github.ref, 'refs/heads/dependabot/')) + github.actor != 'dependabot[bot]' steps: - name: Download Artifacts @@ -240,7 +240,9 @@ jobs: needs: ["test", "build"] # needs: ["test", "e2e", "build"] - if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' + if: > + github.event_name == 'pull_request' && + github.actor == 'dependabot[bot]' steps: - name: "Merge pull request" diff --git a/e2e/src/app.e2e-spec.ts b/e2e/src/app.e2e-spec.ts index b51fb640a..c1e756b61 100644 --- a/e2e/src/app.e2e-spec.ts +++ b/e2e/src/app.e2e-spec.ts @@ -1,13 +1,10 @@ import { AppPage } from "./app.po"; -import { LoginPage } from "./login.po"; describe("workbench-client", () => { let page: AppPage; - let loginPage: LoginPage; beforeEach(() => { page = new AppPage(); - loginPage = new LoginPage(); page.navigateTo(); }); diff --git a/package-lock.json b/package-lock.json index 7de3c8bb0..72aa411fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5219,9 +5219,9 @@ } }, "semver": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", - "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -5256,41 +5256,41 @@ } }, "@typescript-eslint/parser": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.18.0.tgz", - "integrity": "sha512-W3z5S0ZbecwX3PhJEAnq4mnjK5JJXvXUDBYIYGoweCyWyuvAKfGHvzmpUzgB5L4cRBb+cTu9U/ro66dx7dIimA==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.19.0.tgz", + "integrity": "sha512-/uabZjo2ZZhm66rdAu21HA8nQebl3lAIDcybUoOxoI7VbZBYavLIwtOOmykKCJy+Xq6Vw6ugkiwn8Js7D6wieA==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "4.18.0", - "@typescript-eslint/types": "4.18.0", - "@typescript-eslint/typescript-estree": "4.18.0", + "@typescript-eslint/scope-manager": "4.19.0", + "@typescript-eslint/types": "4.19.0", + "@typescript-eslint/typescript-estree": "4.19.0", "debug": "^4.1.1" }, "dependencies": { "@typescript-eslint/scope-manager": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.18.0.tgz", - "integrity": "sha512-olX4yN6rvHR2eyFOcb6E4vmhDPsfdMyfQ3qR+oQNkAv8emKKlfxTWUXU5Mqxs2Fwe3Pf1BoPvrwZtwngxDzYzQ==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.19.0.tgz", + "integrity": "sha512-GGy4Ba/hLXwJXygkXqMzduqOMc+Na6LrJTZXJWVhRrSuZeXmu8TAnniQVKgj8uTRKe4igO2ysYzH+Np879G75g==", "dev": true, "requires": { - "@typescript-eslint/types": "4.18.0", - "@typescript-eslint/visitor-keys": "4.18.0" + "@typescript-eslint/types": "4.19.0", + "@typescript-eslint/visitor-keys": "4.19.0" } }, "@typescript-eslint/types": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.18.0.tgz", - "integrity": "sha512-/BRociARpj5E+9yQ7cwCF/SNOWwXJ3qhjurMuK2hIFUbr9vTuDeu476Zpu+ptxY2kSxUHDGLLKy+qGq2sOg37A==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.19.0.tgz", + "integrity": "sha512-A4iAlexVvd4IBsSTNxdvdepW0D4uR/fwxDrKUa+iEY9UWvGREu2ZyB8ylTENM1SH8F7bVC9ac9+si3LWNxcBuA==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.18.0.tgz", - "integrity": "sha512-wt4xvF6vvJI7epz+rEqxmoNQ4ZADArGQO9gDU+cM0U5fdVv7N+IAuVoVAoZSOZxzGHBfvE3XQMLdy+scsqFfeg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.19.0.tgz", + "integrity": "sha512-3xqArJ/A62smaQYRv2ZFyTA+XxGGWmlDYrsfZG68zJeNbeqRScnhf81rUVa6QG4UgzHnXw5VnMT5cg75dQGDkA==", "dev": true, "requires": { - "@typescript-eslint/types": "4.18.0", - "@typescript-eslint/visitor-keys": "4.18.0", + "@typescript-eslint/types": "4.19.0", + "@typescript-eslint/visitor-keys": "4.19.0", "debug": "^4.1.1", "globby": "^11.0.1", "is-glob": "^4.0.1", @@ -5299,19 +5299,19 @@ } }, "@typescript-eslint/visitor-keys": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.18.0.tgz", - "integrity": "sha512-Q9t90JCvfYaN0OfFUgaLqByOfz8yPeTAdotn/XYNm5q9eHax90gzdb+RJ6E9T5s97Kv/UHWKERTmqA0jTKAEHw==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.19.0.tgz", + "integrity": "sha512-aGPS6kz//j7XLSlgpzU2SeTqHPsmRYxFztj2vPuMMFJXZudpRSehE3WCV+BaxwZFvfAqMoSd86TEuM0PQ59E/A==", "dev": true, "requires": { - "@typescript-eslint/types": "4.18.0", + "@typescript-eslint/types": "4.19.0", "eslint-visitor-keys": "^2.0.0" } }, "semver": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", - "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -5901,14 +5901,119 @@ "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "array-includes": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz", - "integrity": "sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz", + "integrity": "sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==", "dev": true, "requires": { + "call-bind": "^1.0.2", "define-properties": "^1.1.3", - "es-abstract": "^1.17.0", + "es-abstract": "^1.18.0-next.2", + "get-intrinsic": "^1.1.1", "is-string": "^1.0.5" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "es-abstract": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz", + "integrity": "sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.2", + "is-string": "^1.0.5", + "object-inspect": "^1.9.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.0" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + } + } + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "is-callable": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "dev": true + }, + "is-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz", + "integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.1" + } + }, + "object-inspect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==", + "dev": true + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + } } }, "array-union": { @@ -5941,23 +6046,140 @@ }, "dependencies": { "es-abstract": { - "version": "1.18.0-next.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", - "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz", + "integrity": "sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==", "dev": true, "requires": { + "call-bind": "^1.0.2", "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.1", - "is-callable": "^1.2.2", - "is-negative-zero": "^2.0.0", - "is-regex": "^1.1.1", - "object-inspect": "^1.8.0", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.2", + "is-string": "^1.0.5", + "object-inspect": "^1.9.0", "object-keys": "^1.1.1", - "object.assign": "^4.1.1", - "string.prototype.trimend": "^1.0.1", - "string.prototype.trimstart": "^1.0.1" + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.0" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + } + } + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "is-callable": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "dev": true + }, + "is-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz", + "integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.1" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + } + } + }, + "object-inspect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==", + "dev": true + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + } + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "dependencies": { + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + } } } } @@ -9196,9 +9418,9 @@ } }, "eslint": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.22.0.tgz", - "integrity": "sha512-3VawOtjSJUQiiqac8MQc+w457iGLfuNGLFn8JmF051tTKbh5/x/0vlcEj8OgDCaw7Ysa2Jn8paGshV7x2abKXg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.23.0.tgz", + "integrity": "sha512-kqvNVbdkjzpFy0XOszNwjkKzZ+6TcwCQ/h+ozlcIWwaimBBuhlQ4nN6kbiM2L+OjDcznkTJxzYfRFH92sx4a0Q==", "dev": true, "requires": { "@babel/code-frame": "7.12.11", @@ -9305,9 +9527,9 @@ } }, "globals": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.6.0.tgz", - "integrity": "sha512-YFKCX0SiPg7l5oKYCJ2zZGxcXprVXHcSnVuvzrT3oSENQonVLqM5pf9fN5dLGZGyCjhw8TN8Btwe/jKnZ0pjvQ==", + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.7.0.tgz", + "integrity": "sha512-Aipsz6ZKRxa/xQkZhNg0qIWXT6x6rD46f6x/PCnBomlttdIyAPak4YD9jTmKpZ72uROSMU87qJtcgpgHaVchiA==", "dev": true, "requires": { "type-fest": "^0.20.2" @@ -9384,9 +9606,9 @@ "dev": true }, "semver": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", - "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -11077,6 +11299,11 @@ "sshpk": "^1.7.0" } }, + "http-status": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.5.0.tgz", + "integrity": "sha512-wcGvY31MpFNHIkUcXHHnvrE4IKYlpvitJw5P/1u892gMBAM46muQ+RH7UN1d+Ntnfx5apnOnVY6vcLmrWHOLwg==" + }, "https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", @@ -11298,7 +11525,8 @@ }, "ini": { "version": "1.3.5", - "resolved": "", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true }, "inquirer": { @@ -13082,6 +13310,18 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", + "dev": true + }, "lodash.isfinite": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz", @@ -13100,6 +13340,12 @@ "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "dev": true }, + "lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -13788,19 +14034,12 @@ "integrity": "sha512-tXQOSZH2VeOIlYhhCV9gMvbqPA6uOjNTnyrGQqVvEm4mySJ2lqcI2MX+4CDF5KrtnEHT27pXdxeAqwOenDoonQ==", "dev": true }, - "ng-recaptcha3": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/ng-recaptcha3/-/ng-recaptcha3-1.3.1.tgz", - "integrity": "sha512-NNdpyJzwwfIfpH5JksQz9ahKADVSequ6zTtQ6OXktWnOjVNdnSfoYjOJH5L7Ckcx5OTrUdPWSjbuGMBnu2ZJ1g==", + "ngx-captcha": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ngx-captcha/-/ngx-captcha-9.0.1.tgz", + "integrity": "sha512-Fq83lO/TlTktaNxaOGygDTTsCc0DTZYDqYPRXZdiA+nSH2y4LE8yWSfSWwSxf2Vl/AldUWU7igWtFoVidVOXmw==", "requires": { - "tslib": "^1.9.0" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } + "tslib": "^2.0.0" } }, "ngx-cookie-service": { @@ -19592,21 +19831,26 @@ "dev": true }, "table": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/table/-/table-6.0.7.tgz", - "integrity": "sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g==", + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/table/-/table-6.0.8.tgz", + "integrity": "sha512-OBAdezyozae8IvjHGXBDHByVkLCcsmffXUSj8LXkNb0SluRd4ug3GFCjk6JynZONIPhOkyr0Nnvbq1rlIspXyQ==", "dev": true, "requires": { - "ajv": "^7.0.2", - "lodash": "^4.17.20", + "ajv": "^8.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "lodash.clonedeep": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.0" }, "dependencies": { "ajv": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.2.1.tgz", - "integrity": "sha512-+nu0HDv7kNSOua9apAVc979qd932rrZeb3WOvoiD31A/p1mIE5/9bN2027pE2rOPYEdS3UHzsvof4hY+lM9/WQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.0.1.tgz", + "integrity": "sha512-46ZA4TalFcLLqX1dEU3dhdY38wAtDydJ4e7QQTVekLUTzXkb1LfqU6VOBXC/a9wiv4T094WURqJH6ZitF92Kqw==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", diff --git a/package.json b/package.json index 119432226..c868bde9d 100644 --- a/package.json +++ b/package.json @@ -57,13 +57,14 @@ "express": "^4.17.1", "filesize": "^6.1.0", "full-icu": "^1.3.1", + "http-status": "^1.5.0", "humanize-duration": "^3.25.1", "immutable": "^4.0.0-rc.12", "include-media": "^1.4.9", "just-camel-case": "^4.0.2", "just-snake-case": "^1.1.0", "luxon": "^1.26.0", - "ng-recaptcha3": "^1.3.1", + "ngx-captcha": "^9.0.1", "ngx-cookie-service": "^11.0.2", "ngx-device-detector": "^2.0.6", "ngx-line-truncation": "^1.6.8", @@ -94,11 +95,11 @@ "@types/jasminewd2": "^2.0.8", "@types/luxon": "^1.26.2", "@types/node": "^14.14.35", - "@typescript-eslint/eslint-plugin": "4.18.0", - "@typescript-eslint/parser": "^4.18.0", + "@typescript-eslint/eslint-plugin": "^4.18.0", + "@typescript-eslint/parser": "^4.19.0", "codelyzer": "^6.0.1", - "eslint": "^7.22.0", - "eslint-plugin-import": "2.22.1", + "eslint": "^7.23.0", + "eslint-plugin-import": "^2.22.1", "eslint-plugin-jsdoc": "32.3.0", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-rxjs": "latest", diff --git a/src/app/components/about/pages/contact-us/contact-us.component.spec.ts b/src/app/components/about/pages/contact-us/contact-us.component.spec.ts index 71cbfe570..4bf12f74d 100644 --- a/src/app/components/about/pages/contact-us/contact-us.component.spec.ts +++ b/src/app/components/about/pages/contact-us/contact-us.component.spec.ts @@ -1,16 +1,24 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { MockAppConfigModule } from "@services/config/configMock.module"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; import { SharedModule } from "@shared/shared.module"; import { appLibraryImports } from "src/app/app.module"; import { ContactUsComponent } from "./contact-us.component"; +// TODO Implement tests + describe("ContactUsComponent", () => { let component: ContactUsComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ - imports: [...appLibraryImports, SharedModule, MockAppConfigModule], + imports: [ + ...appLibraryImports, + SharedModule, + MockBawApiModule, + RouterTestingModule, + ], declarations: [ContactUsComponent], }).compileComponents(); diff --git a/src/app/components/about/pages/contact-us/contact-us.component.ts b/src/app/components/about/pages/contact-us/contact-us.component.ts index 2cbee6802..5716e9213 100644 --- a/src/app/components/about/pages/contact-us/contact-us.component.ts +++ b/src/app/components/about/pages/contact-us/contact-us.component.ts @@ -1,49 +1,92 @@ import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + ContactUs, + IContactUs, + ContactUsService, +} from "@baw-api/report/contact-us.service"; import { aboutCategory, contactUsMenuItem, } from "@components/about/about.menus"; -import { withFormCheck } from "@guards/form/form.guard"; -import { PageComponent } from "@helpers/page/pageComponent"; +import { dataRequestMenuItem } from "@components/data-request/data-request.menus"; +import { reportProblemMenuItem } from "@components/report-problem/report-problem.menus"; +import { + defaultErrorMsg, + FormTemplate, +} from "@helpers/formTemplate/formTemplate"; +import { RecaptchaState } from "@shared/form/form.component"; +import { ToastrService } from "ngx-toastr"; +import { takeUntil } from "rxjs/operators"; import { fields } from "./contact-us.schema.json"; @Component({ selector: "baw-about-contact-us", template: ` - - - + + + This form is for general enquiries. We have separate forms to + request data or + report issues. + + `, }) -class ContactUsComponent - extends withFormCheck(PageComponent) - implements OnInit { - public model = {}; +class ContactUsComponent extends FormTemplate implements OnInit { public fields = fields; - public loading: boolean; + public recaptchaSeed: RecaptchaState = { state: "loading" }; + public dataRequestRoute = dataRequestMenuItem.route; + public reportProblemRoute = reportProblemMenuItem.route; + + public constructor( + private api: ContactUsService, + notifications: ToastrService, + route: ActivatedRoute, + router: Router + ) { + super( + notifications, + route, + router, + undefined, + () => + "Thank you for contacting us. " + + "If you've asked us to contact you or we need more information, " + + "we will be in touch with you shortly.", + defaultErrorMsg + ); + } + + public ngOnInit() { + super.ngOnInit(); + + this.api + .seed() + .pipe(takeUntil(this.unsubscribe)) + .subscribe( + ({ seed, action }) => + (this.recaptchaSeed = { state: "loaded", action, seed }), + (err) => { + console.error(err); + this.notifications.error("Failed to load form"); + } + ); + } - public constructor() { - super(); + protected apiAction(model: IContactUs) { + return this.api.contactUs(new ContactUs(model)); } - public ngOnInit() {} - - /** - * Form submission - * - * @param $event Form response - */ - public submit($event: any) { - this.loading = true; - console.log($event); - this.loading = false; + protected redirectUser() { + // Do nothing } } diff --git a/src/app/components/about/pages/contact-us/contact-us.schema.json b/src/app/components/about/pages/contact-us/contact-us.schema.json index e23e50f01..e44327afb 100644 --- a/src/app/components/about/pages/contact-us/contact-us.schema.json +++ b/src/app/components/about/pages/contact-us/contact-us.schema.json @@ -18,7 +18,7 @@ } }, { - "key": "message", + "key": "content", "type": "textarea", "templateOptions": { "label": "Message", diff --git a/src/app/components/admin/analysis-jobs/list/list.component.spec.ts b/src/app/components/admin/analysis-jobs/list/list.component.spec.ts index a4b41962f..3a736ad21 100644 --- a/src/app/components/admin/analysis-jobs/list/list.component.spec.ts +++ b/src/app/components/admin/analysis-jobs/list/list.component.spec.ts @@ -12,7 +12,6 @@ import { AdminAnalysisJobsComponent } from "./list.component"; describe("AdminAnalysisJobsComponent", () => { let api: AnalysisJobsService; - let defaultModel: AnalysisJob; let defaultModels: AnalysisJob[]; let fixture: ComponentFixture; @@ -30,7 +29,6 @@ describe("AdminAnalysisJobsComponent", () => { fixture = TestBed.createComponent(AdminAnalysisJobsComponent); api = TestBed.inject(AnalysisJobsService); - defaultModel = new AnalysisJob(generateAnalysisJob()); defaultModels = []; for (let i = 0; i < defaultApiPageSize; i++) { defaultModels.push(new AnalysisJob(generateAnalysisJob())); diff --git a/src/app/components/admin/audio-recordings/list/list.component.spec.ts b/src/app/components/admin/audio-recordings/list/list.component.spec.ts index 6c17f5d63..cfcf7f739 100644 --- a/src/app/components/admin/audio-recordings/list/list.component.spec.ts +++ b/src/app/components/admin/audio-recordings/list/list.component.spec.ts @@ -12,7 +12,6 @@ import { AdminAudioRecordingsComponent } from "./list.component"; describe("AdminAudioRecordingsComponent", () => { let api: AudioRecordingsService; - let defaultModel: AudioRecording; let defaultModels: AudioRecording[]; let fixture: ComponentFixture; @@ -30,7 +29,6 @@ describe("AdminAudioRecordingsComponent", () => { fixture = TestBed.createComponent(AdminAudioRecordingsComponent); api = TestBed.inject(AudioRecordingsService); - defaultModel = new AudioRecording(generateAudioRecording()); defaultModels = []; for (let i = 0; i < defaultApiPageSize; i++) { defaultModels.push(new AudioRecording(generateAudioRecording())); diff --git a/src/app/components/admin/orphan/list/list.component.spec.ts b/src/app/components/admin/orphan/list/list.component.spec.ts index 200b4c8ed..6a979ed4d 100644 --- a/src/app/components/admin/orphan/list/list.component.spec.ts +++ b/src/app/components/admin/orphan/list/list.component.spec.ts @@ -12,7 +12,6 @@ import { AdminOrphansComponent } from "./list.component"; describe("AdminOrphansComponent", () => { let api: ShallowSitesService; - let defaultModel: Site; let defaultModels: Site[]; let fixture: ComponentFixture; @@ -30,7 +29,6 @@ describe("AdminOrphansComponent", () => { fixture = TestBed.createComponent(AdminOrphansComponent); api = TestBed.inject(ShallowSitesService); - defaultModel = new Site(generateSite()); defaultModels = []; for (let i = 0; i < defaultApiPageSize; i++) { defaultModels.push(new Site(generateSite())); diff --git a/src/app/components/admin/orphan/list/list.component.ts b/src/app/components/admin/orphan/list/list.component.ts index 805a1dcf7..98c2e3c0b 100644 --- a/src/app/components/admin/orphan/list/list.component.ts +++ b/src/app/components/admin/orphan/list/list.component.ts @@ -8,11 +8,7 @@ import { PagedTableTemplate } from "@helpers/tableTemplate/pagedTableTemplate"; import { Id } from "@interfaces/apiInterfaces"; import { Site } from "@models/Site"; import { List } from "immutable"; -import { - adminOrphanMenuItem, - adminOrphansCategory, - adminOrphansMenuItem, -} from "../orphans.menus"; +import { adminOrphansCategory, adminOrphansMenuItem } from "../orphans.menus"; @Component({ selector: "baw-admin-orphans", diff --git a/src/app/components/admin/scripts/list/list.component.spec.ts b/src/app/components/admin/scripts/list/list.component.spec.ts index 8991dc311..095f86215 100644 --- a/src/app/components/admin/scripts/list/list.component.spec.ts +++ b/src/app/components/admin/scripts/list/list.component.spec.ts @@ -12,7 +12,6 @@ import { AdminScriptsComponent } from "./list.component"; describe("AdminScriptsComponent", () => { let api: ScriptsService; - let defaultModel: Script; let defaultModels: Script[]; let fixture: ComponentFixture; @@ -30,7 +29,6 @@ describe("AdminScriptsComponent", () => { fixture = TestBed.createComponent(AdminScriptsComponent); api = TestBed.inject(ScriptsService); - defaultModel = new Script(generateScript()); defaultModels = []; for (let i = 0; i < defaultApiPageSize; i++) { defaultModels.push(new Script(generateScript(i))); diff --git a/src/app/components/admin/tag-group/list/list.component.spec.ts b/src/app/components/admin/tag-group/list/list.component.spec.ts index b88843aee..a480bf8cf 100644 --- a/src/app/components/admin/tag-group/list/list.component.spec.ts +++ b/src/app/components/admin/tag-group/list/list.component.spec.ts @@ -12,7 +12,6 @@ import { AdminTagGroupsComponent } from "./list.component"; describe("AdminTagGroupsComponent", () => { let api: TagGroupsService; - let defaultModel: TagGroup; let defaultModels: TagGroup[]; let fixture: ComponentFixture; @@ -30,7 +29,6 @@ describe("AdminTagGroupsComponent", () => { fixture = TestBed.createComponent(AdminTagGroupsComponent); api = TestBed.inject(TagGroupsService); - defaultModel = new TagGroup(generateTagGroup()); defaultModels = []; for (let i = 0; i < defaultApiPageSize; i++) { defaultModels.push(new TagGroup(generateTagGroup())); diff --git a/src/app/components/admin/tags/list/list.component.spec.ts b/src/app/components/admin/tags/list/list.component.spec.ts index 7aff74f56..31dc0a81e 100644 --- a/src/app/components/admin/tags/list/list.component.spec.ts +++ b/src/app/components/admin/tags/list/list.component.spec.ts @@ -12,7 +12,6 @@ import { AdminTagsComponent } from "./list.component"; describe("AdminTagsComponent", () => { let api: TagsService; - let defaultModel: Tag; let defaultModels: Tag[]; let fixture: ComponentFixture; @@ -30,7 +29,6 @@ describe("AdminTagsComponent", () => { fixture = TestBed.createComponent(AdminTagsComponent); api = TestBed.inject(TagsService); - defaultModel = new Tag(generateTag()); defaultModels = []; for (let i = 0; i < defaultApiPageSize; i++) { defaultModels.push(new Tag(generateTag(i))); diff --git a/src/app/components/data-request/data-request.component.spec.ts b/src/app/components/data-request/data-request.component.spec.ts index 5e94f05c3..ea630f9b0 100644 --- a/src/app/components/data-request/data-request.component.spec.ts +++ b/src/app/components/data-request/data-request.component.spec.ts @@ -1,19 +1,13 @@ -import { - HttpClientTestingModule, - HttpTestingController, -} from "@angular/common/http/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; -import { ConfigService } from "@services/config/config.service"; import { MockAppConfigModule } from "@services/config/configMock.module"; import { SharedModule } from "@shared/shared.module"; import { appLibraryImports } from "src/app/app.module"; import { DataRequestComponent } from "./data-request.component"; xdescribe("DataRequestComponent", () => { - let httpMock: HttpTestingController; let component: DataRequestComponent; - let config: ConfigService; let fixture: ComponentFixture; beforeEach(() => { @@ -29,8 +23,6 @@ xdescribe("DataRequestComponent", () => { }).compileComponents(); fixture = TestBed.createComponent(DataRequestComponent); - httpMock = TestBed.inject(HttpTestingController); - config = TestBed.inject(ConfigService); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/components/error/resolver-handler.component.spec.ts b/src/app/components/error/resolver-handler.component.spec.ts index 902ec0587..e9e90de83 100644 --- a/src/app/components/error/resolver-handler.component.spec.ts +++ b/src/app/components/error/resolver-handler.component.spec.ts @@ -11,7 +11,7 @@ import { ResolverHandlerComponent } from "./resolver-handler.component"; const mockErrorHandler = MockComponent(ErrorHandlerComponent); -describe("PageNotFoundComponent", () => { +describe("ResolverHandlerComponent", () => { let spec: SpectatorRouting; const createComponent = createRoutingFactory({ component: ResolverHandlerComponent, diff --git a/src/app/components/error/resolver-handler.component.ts b/src/app/components/error/resolver-handler.component.ts index 1bfca1e3b..060b18b1e 100644 --- a/src/app/components/error/resolver-handler.component.ts +++ b/src/app/components/error/resolver-handler.component.ts @@ -1,10 +1,11 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; -import { apiReturnCodes } from "@baw-api/baw-api.service"; +import { unknownErrorCode } from "@baw-api/baw-api.service"; import { IPageInfo } from "@helpers/page/pageInfo"; import { withUnsubscribe } from "@helpers/unsubscribe/unsubscribe"; import { takeUntil } from "rxjs/operators"; +import httpCodes from "http-status"; @Component({ template: ` @@ -27,7 +28,7 @@ export class ResolverHandlerComponent (err) => { console.error("ErrorHandlerComponent: ", err); this.error = { - status: apiReturnCodes.unknown, + status: unknownErrorCode, message: "Unable to load data from Server.", }; } @@ -52,7 +53,7 @@ export class ResolverHandlerComponent this.error = data[key].error; // If unauthorized response, no point downgrading to "Not Found" - if (this.error.status === apiReturnCodes.unauthorized) { + if (this.error.status === httpCodes.UNAUTHORIZED) { return; } } diff --git a/src/app/components/profile/pages/my-edit/my-edit.component.spec.ts b/src/app/components/profile/pages/my-edit/my-edit.component.spec.ts index 9cbd20a4f..6cb2b6817 100644 --- a/src/app/components/profile/pages/my-edit/my-edit.component.spec.ts +++ b/src/app/components/profile/pages/my-edit/my-edit.component.spec.ts @@ -3,7 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; -import { userResolvers, UserService } from "@baw-api/user/user.service"; +import { userResolvers } from "@baw-api/user/user.service"; import { User } from "@models/User"; import { SharedModule } from "@shared/shared.module"; import { generateUser } from "@test/fakes/User"; @@ -12,7 +12,6 @@ import { appLibraryImports } from "src/app/app.module"; import { MyEditComponent } from "./my-edit.component"; describe("MyProfileEditComponent", () => { - let api: UserService; let component: MyEditComponent; let fixture: ComponentFixture; let defaultUser: User; @@ -39,7 +38,6 @@ describe("MyProfileEditComponent", () => { fixture = TestBed.createComponent(MyEditComponent); component = fixture.componentInstance; - api = TestBed.inject(UserService); fixture.detectChanges(); } diff --git a/src/app/components/projects/harvest-complete/harvest-complete.component.ts b/src/app/components/projects/harvest-complete/harvest-complete.component.ts index 098346606..404ca69c9 100644 --- a/src/app/components/projects/harvest-complete/harvest-complete.component.ts +++ b/src/app/components/projects/harvest-complete/harvest-complete.component.ts @@ -33,7 +33,7 @@ export class HarvestCompleteComponent ); } - public playPath(site: Site) { + public playPath(_site: Site) { // TODO Fix this, need audio recording for site // listenRecordingMenuItem return getUnknownViewUrl("Feature not implemented yet"); diff --git a/src/app/components/projects/pages/details/details.component.spec.ts b/src/app/components/projects/pages/details/details.component.spec.ts index b6a6bd05d..cbbed0f58 100644 --- a/src/app/components/projects/pages/details/details.component.spec.ts +++ b/src/app/components/projects/pages/details/details.component.spec.ts @@ -1,4 +1,3 @@ -import { componentFactoryName } from "@angular/compiler"; import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; import { defaultApiPageSize } from "@baw-api/baw-api.service"; import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; diff --git a/src/app/components/projects/pages/permissions/permissions.component.spec.ts b/src/app/components/projects/pages/permissions/permissions.component.spec.ts index 5da261ef9..4c1ffa1ca 100644 --- a/src/app/components/projects/pages/permissions/permissions.component.spec.ts +++ b/src/app/components/projects/pages/permissions/permissions.component.spec.ts @@ -3,10 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; -import { - projectResolvers, - ProjectsService, -} from "@baw-api/project/projects.service"; +import { projectResolvers } from "@baw-api/project/projects.service"; import { Project } from "@models/Project"; import { SharedModule } from "@shared/shared.module"; import { generateProject } from "@test/fakes/Project"; @@ -15,7 +12,6 @@ import { appLibraryImports } from "src/app/app.module"; import { PermissionsComponent } from "./permissions.component"; describe("PermissionsComponent", () => { - let api: ProjectsService; let component: PermissionsComponent; let defaultProject: Project; let fixture: ComponentFixture; @@ -42,7 +38,6 @@ describe("PermissionsComponent", () => { fixture = TestBed.createComponent(PermissionsComponent); component = fixture.componentInstance; - api = TestBed.inject(ProjectsService); fixture.detectChanges(); } diff --git a/src/app/components/projects/site-card/site-card.component.spec.ts b/src/app/components/projects/site-card/site-card.component.spec.ts index 39c7a6f44..7e3cba507 100644 --- a/src/app/components/projects/site-card/site-card.component.spec.ts +++ b/src/app/components/projects/site-card/site-card.component.spec.ts @@ -2,7 +2,6 @@ import { RouterTestingModule } from "@angular/router/testing"; import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; import { AudioRecordingsService } from "@baw-api/audio-recording/audio-recordings.service"; import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; -import { listenRecordingMenuItem } from "@components/listen/listen.menus"; import { AudioRecording } from "@models/AudioRecording"; import { Project } from "@models/Project"; import { Region } from "@models/Region"; @@ -16,7 +15,7 @@ import { generateProject } from "@test/fakes/Project"; import { generateRegion } from "@test/fakes/Region"; import { generateSite } from "@test/fakes/Site"; import { nStepObservable } from "@test/helpers/general"; -import { assertImage, assertRoute, assertUrl } from "@test/helpers/html"; +import { assertImage, assertUrl } from "@test/helpers/html"; import { websiteHttpUrl } from "@test/helpers/url"; import { Subject } from "rxjs"; import { SiteCardComponent } from "./site-card.component"; diff --git a/src/app/components/report-problem/report-problem.component.spec.ts b/src/app/components/report-problem/report-problem.component.spec.ts index 41f1e07ba..b8eea60f4 100644 --- a/src/app/components/report-problem/report-problem.component.spec.ts +++ b/src/app/components/report-problem/report-problem.component.spec.ts @@ -1,16 +1,24 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { MockAppConfigModule } from "@services/config/configMock.module"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; import { SharedModule } from "@shared/shared.module"; import { appLibraryImports } from "src/app/app.module"; import { ReportProblemComponent } from "./report-problem.component"; +// TODO Implement tests + describe("ReportProblemComponent", () => { let component: ReportProblemComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ - imports: [...appLibraryImports, SharedModule, MockAppConfigModule], + imports: [ + ...appLibraryImports, + SharedModule, + MockBawApiModule, + RouterTestingModule, + ], declarations: [ReportProblemComponent], }).compileComponents(); diff --git a/src/app/components/report-problem/report-problem.component.ts b/src/app/components/report-problem/report-problem.component.ts index f44525d2f..e2faf87e4 100644 --- a/src/app/components/report-problem/report-problem.component.ts +++ b/src/app/components/report-problem/report-problem.component.ts @@ -1,7 +1,17 @@ import { Component, OnInit } from "@angular/core"; -import { withFormCheck } from "@guards/form/form.guard"; -import { PageComponent } from "@helpers/page/pageComponent"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + ReportProblem, + ReportProblemService, +} from "@baw-api/report/report-problem.service"; +import { + defaultErrorMsg, + FormTemplate, +} from "@helpers/formTemplate/formTemplate"; import { ConfigService } from "@services/config/config.service"; +import { RecaptchaState } from "@shared/form/form.component"; +import { ToastrService } from "ngx-toastr"; +import { takeUntil } from "rxjs/operators"; import { reportProblemMenuItem, reportProblemsCategory, @@ -11,48 +21,72 @@ import { fields } from "./report-problem.schema.json"; @Component({ selector: "baw-report-problem", template: ` - - - + + + Complete the form below to report a problem. Alternatively, we have a + Github Issues page. + + `, }) class ReportProblemComponent - extends withFormCheck(PageComponent) + extends FormTemplate implements OnInit { - public model = {}; public fields = fields; - public loading: boolean; + public recaptchaSeed: RecaptchaState = { state: "loading" }; public subTitle: string; + public sourceRepoLink: string; - public constructor(private config: ConfigService) { - super(); + public constructor( + private api: ReportProblemService, + private config: ConfigService, + notifications: ToastrService, + route: ActivatedRoute, + router: Router + ) { + super( + notifications, + route, + router, + undefined, + () => + "Thank you, your report was successfully submitted." + + "If you entered an email address, we will let you know if the problems you describe are resolved.", + defaultErrorMsg + ); } public ngOnInit() { - this.subTitle = ` - Complete the form below to report a problem. - Alternatively, we have a - Github Issues page. - `; + super.ngOnInit(); + + this.sourceRepoLink = this.config.values.links.sourceRepository; + this.api + .seed() + .pipe(takeUntil(this.unsubscribe)) + .subscribe( + ({ seed, action }) => + (this.recaptchaSeed = { state: "loaded", seed, action }), + (err) => { + console.error(err); + this.notifications.error("Failed to load form"); + } + ); + } + + protected apiAction(model: ReportProblem) { + return this.api.reportProblem(new ReportProblem(model)); } - /** - * Form submission - * - * @param $event Form response - */ - public submit($event: any) { - this.loading = true; - console.log($event); - this.loading = false; + protected redirectUser() { + // Do nothing } } diff --git a/src/app/components/report-problem/report-problem.schema.json b/src/app/components/report-problem/report-problem.schema.json index a09116b27..b458b8bbd 100644 --- a/src/app/components/report-problem/report-problem.schema.json +++ b/src/app/components/report-problem/report-problem.schema.json @@ -27,7 +27,7 @@ } }, { - "key": "steps", + "key": "description", "type": "textarea", "templateOptions": { "label": "Steps to reproduce issue", @@ -36,7 +36,7 @@ } }, { - "key": "expect", + "key": "content", "type": "textarea", "templateOptions": { "label": "What do you expect to happen? What actually happens?", diff --git a/src/app/components/security/pages/register/register.component.spec.ts b/src/app/components/security/pages/register/register.component.spec.ts index 9c58aaa99..4ba80b466 100644 --- a/src/app/components/security/pages/register/register.component.spec.ts +++ b/src/app/components/security/pages/register/register.component.spec.ts @@ -1,4 +1,5 @@ import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { RecaptchaSettings } from "@baw-api/baw-form-api.service"; import { RegisterDetails, SecurityService, @@ -95,18 +96,23 @@ describe("RegisterComponent", () => { expect(spec.component.recaptchaSeed).toEqual({ state: "loading" }); }); - it("should set recaptcha seed", async () => { + it("should set recaptcha settings", async () => { const seed = modelData.random.alpha({ count: 10 }); - const subject = new Subject(); - const promise = nStepObservable(subject, () => seed); + const action = "register"; + const subject = new Subject(); + const promise = nStepObservable(subject, () => ({ seed, action })); spyOn(api, "signUpSeed").and.callFake(() => subject); spec.detectChanges(); await promise; - expect(spec.component.recaptchaSeed).toEqual({ state: "loaded", seed }); + expect(spec.component.recaptchaSeed).toEqual({ + state: "loaded", + seed, + action, + }); }); it("should show error if failed to capture recaptcha seed", async () => { - const subject = new Subject(); + const subject = new Subject(); const promise = nStepObservable( subject, () => generateApiErrorDetails(), diff --git a/src/app/components/security/pages/register/register.component.ts b/src/app/components/security/pages/register/register.component.ts index d8e76e627..501722804 100644 --- a/src/app/components/security/pages/register/register.component.ts +++ b/src/app/components/security/pages/register/register.component.ts @@ -38,7 +38,6 @@ class RegisterComponent extends FormTemplate implements OnInit { public fields = fields; - public loading: boolean; public recaptchaSeed: RecaptchaState = { state: "loading" }; public constructor( @@ -72,7 +71,8 @@ class RegisterComponent .signUpSeed() .pipe(takeUntil(this.unsubscribe)) .subscribe( - (seed) => (this.recaptchaSeed = { state: "loaded", seed }), + ({ seed, action }) => + (this.recaptchaSeed = { state: "loaded", seed, action }), (err) => { console.error(err); this.notifications.error("Failed to load form"); diff --git a/src/app/components/shared/action-menu/action-menu.component.spec.ts b/src/app/components/shared/action-menu/action-menu.component.spec.ts index d11aa8fad..e7bc2c588 100644 --- a/src/app/components/shared/action-menu/action-menu.component.spec.ts +++ b/src/app/components/shared/action-menu/action-menu.component.spec.ts @@ -1,4 +1,3 @@ -import { Params } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { defaultMenu } from "@helpers/page/defaultMenus"; import { IPageInfo } from "@helpers/page/pageInfo"; diff --git a/src/app/components/shared/cms/cms.component.spec.ts b/src/app/components/shared/cms/cms.component.spec.ts index b407574b1..fca22d0d9 100644 --- a/src/app/components/shared/cms/cms.component.spec.ts +++ b/src/app/components/shared/cms/cms.component.spec.ts @@ -18,7 +18,6 @@ import { CmsComponent } from "./cms.component"; describe("CmsComponent", () => { let cmsService: SpyObject; - let component: CmsComponent; let spectator: Spectator; const createComponent = createComponentFactory({ component: CmsComponent, @@ -40,7 +39,6 @@ describe("CmsComponent", () => { beforeEach(() => { spectator = createComponent({ detectChanges: false }); - component = spectator.component; cmsService = spectator.inject(CmsService); }); diff --git a/src/app/components/shared/detail-view/render-field/render-field.component.ts b/src/app/components/shared/detail-view/render-field/render-field.component.ts index aa5bf72fa..77d801f6c 100644 --- a/src/app/components/shared/detail-view/render-field/render-field.component.ts +++ b/src/app/components/shared/detail-view/render-field/render-field.component.ts @@ -282,7 +282,7 @@ export class RenderFieldComponent invalidCallback: () => void ) { // Url from https://urlregex.com/ - // eslint-disable-next-line max-len + // eslint-disable-next-line max-len, no-useless-escape const urlRegex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/; if (!urlRegex.test(src)) { invalidCallback(); diff --git a/src/app/components/shared/error-handler/error-handler.component.spec.ts b/src/app/components/shared/error-handler/error-handler.component.spec.ts index d3273323a..9f4a7cb71 100644 --- a/src/app/components/shared/error-handler/error-handler.component.spec.ts +++ b/src/app/components/shared/error-handler/error-handler.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnInit } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; -import { apiReturnCodes } from "@baw-api/baw-api.service"; +import { unknownErrorCode } from "@baw-api/baw-api.service"; import { MockAppConfigModule } from "@services/config/configMock.module"; import { generateApiErrorDetails } from "@test/fakes/ApiErrorDetails"; import { SharedModule } from "../shared.module"; @@ -117,7 +117,7 @@ describe("ErrorHandlerComponent", () => { it("should handle unknown code", () => { component.error = generateApiErrorDetails("Custom", { - status: apiReturnCodes.unknown, + status: unknownErrorCode, message: "Unknown error has occurred.", }); fixture.detectChanges(); diff --git a/src/app/components/shared/error-handler/error-handler.component.ts b/src/app/components/shared/error-handler/error-handler.component.ts index 418b0b9e1..899d3211c 100644 --- a/src/app/components/shared/error-handler/error-handler.component.ts +++ b/src/app/components/shared/error-handler/error-handler.component.ts @@ -1,7 +1,7 @@ import { Component, Input } from "@angular/core"; import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; -import { apiReturnCodes } from "@baw-api/baw-api.service"; import { reportProblemMenuItem } from "@components/report-problem/report-problem.menus"; +import httpCodes from "http-status"; /** * Error Handler Wrapper @@ -12,13 +12,13 @@ import { reportProblemMenuItem } from "@components/report-problem/report-problem

- + Unauthorized Access - + Access Forbidden - + Not Found Unknown Error @@ -37,5 +37,5 @@ import { reportProblemMenuItem } from "@components/report-problem/report-problem export class ErrorHandlerComponent { @Input() public error: ApiErrorDetails; public reportProblem = reportProblemMenuItem.route; - public apiReturnCodes = apiReturnCodes; + public httpCodes = httpCodes; } diff --git a/src/app/components/shared/form/form.component.html b/src/app/components/shared/form/form.component.html index 86afcce3b..ce3ed503e 100644 --- a/src/app/components/shared/form/form.component.html +++ b/src/app/components/shared/form/form.component.html @@ -4,8 +4,9 @@ [ngClass]="{ small: size === 'small' }" >

- -
+ +
+
diff --git a/src/app/components/shared/form/form.component.scss b/src/app/components/shared/form/form.component.scss index ee64b31f1..aee946363 100644 --- a/src/app/components/shared/form/form.component.scss +++ b/src/app/components/shared/form/form.component.scss @@ -30,3 +30,8 @@ input[type="file"] { border: 0px; height: auto; } + +small { + display: inline-block; + height: auto; +} diff --git a/src/app/components/shared/form/form.component.spec.ts b/src/app/components/shared/form/form.component.spec.ts index 44a68385a..b8eb88905 100644 --- a/src/app/components/shared/form/form.component.spec.ts +++ b/src/app/components/shared/form/form.component.spec.ts @@ -1,6 +1,5 @@ -import { HttpTestingController } from "@angular/common/http/testing"; import { DebugElement } from "@angular/core"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { TestBed } from "@angular/core/testing"; import { BootstrapColorTypes } from "@helpers/bootstrapTypes"; import { createComponentFactory, Spectator } from "@ngneat/spectator"; import { FormlyFieldConfig } from "@ngx-formly/core"; @@ -317,14 +316,14 @@ describe("FormComponent", () => { }); }); - // TODO Implement + // TODO Add tests for recaptcha describe("Recaptcha", () => { it("should disable submit button while loading recaptcha seed", () => {}); - it("should re-enable submit button when recaptcha seed loaded", () => {}); it("should display error notification if recaptcha fails to load", () => {}); it("should insert recaptcha token into model on submit", () => {}); }); // TODO Add tests for spinner + // TODO Add test for subTitle html input }); diff --git a/src/app/components/shared/form/form.component.ts b/src/app/components/shared/form/form.component.ts index 6899b0c56..ac88b9508 100644 --- a/src/app/components/shared/form/form.component.ts +++ b/src/app/components/shared/form/form.component.ts @@ -3,7 +3,6 @@ import { EventEmitter, Input, OnChanges, - OnDestroy, Output, ViewEncapsulation, } from "@angular/core"; @@ -12,7 +11,7 @@ import { BootstrapColorTypes } from "@helpers/bootstrapTypes"; import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; import { withUnsubscribe } from "@helpers/unsubscribe/unsubscribe"; import { FormlyFieldConfig } from "@ngx-formly/core"; -import { NgRecaptcha3Service } from "ng-recaptcha3"; +import { ReCaptchaV3Service } from "ngx-captcha"; import { ToastrService } from "ngx-toastr"; /** @@ -25,9 +24,7 @@ import { ToastrService } from "ngx-toastr"; // eslint-disable-next-line @angular-eslint/use-component-view-encapsulation encapsulation: ViewEncapsulation.None, }) -export class FormComponent - extends withUnsubscribe() - implements OnChanges, OnDestroy { +export class FormComponent extends withUnsubscribe() implements OnChanges { @Input() public btnColor: BootstrapColorTypes = "primary"; @Input() public fields: FormlyFieldConfig[] = []; @Input() public model: Record = {}; @@ -41,7 +38,6 @@ export class FormComponent * seed is loaded */ @Input() public recaptchaSeed?: RecaptchaState; - private loadingSeed: boolean; // Rename is required to stop formly from hijacking the variable // eslint-disable-next-line @angular-eslint/no-output-rename @@ -51,35 +47,18 @@ export class FormComponent public constructor( private notifications: ToastrService, - private recaptcha: NgRecaptcha3Service + private recaptcha: ReCaptchaV3Service ) { super(); } - public async ngOnChanges() { - if (!isInstantiated(this.recaptchaSeed) || this.loadingSeed) { + public ngOnChanges() { + if (!isInstantiated(this.recaptchaSeed)) { return; } - if (this.recaptchaSeed.state === "loading") { - this.submitLoading = true; - } else if (this.recaptchaSeed.state === "loaded") { - this.loadingSeed = true; - const status = (await this.recaptcha.init( - this.recaptchaSeed.seed - )) as RecaptchaStatus; - - if (status === "error") { - this.notifications.error("Failed to load recaptcha"); - return; - } - - this.submitLoading = false; - } - } - - public ngOnDestroy() { - this.recaptcha.destroy(); + // Submit button should be inactive while retrieving recaptcha seed + this.submitLoading = this.recaptchaSeed.state === "loading"; } /** @@ -88,14 +67,24 @@ export class FormComponent * @param model Form response */ public async onSubmit(model: any) { - if (this.form.status === "VALID") { - return this.submit.emit( - this.recaptchaSeed - ? { ...model, recaptchaToken: await this.recaptcha.getToken() } - : model - ); - } else { + if (this.form.status !== "VALID") { this.notifications.error("Please fill all required fields."); + return; + } + + if (!this.recaptchaSeed) { + return this.submit.emit(model); + } + + try { + const { seed, action } = this.recaptchaSeed as RecaptchaLoadedState; + const token = await this.recaptcha.executeAsPromise(seed, action); + return this.submit.emit({ ...model, recaptchaToken: token }); + } catch (err) { + console.error(err); + this.notifications.error( + "Recaptcha failed, please try refreshing the website." + ); } } } @@ -106,6 +95,7 @@ interface RecaptchaLoadingState { interface RecaptchaLoadedState { state: "loaded"; seed: string; + action: string; } export type RecaptchaState = RecaptchaLoadedState | RecaptchaLoadingState; export type RecaptchaStatus = "success" | "error"; diff --git a/src/app/components/shared/formly/file-input.directive.ts b/src/app/components/shared/formly/file-input.directive.ts index f8afc025b..7585be9a9 100644 --- a/src/app/components/shared/formly/file-input.directive.ts +++ b/src/app/components/shared/formly/file-input.directive.ts @@ -25,10 +25,10 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; // https://github.com/angular/angular/issues/7341 export class FileValueAccessorDirective implements ControlValueAccessor { public value: any; - public onChange = (_) => {}; + public onChange = () => {}; public onTouched = () => {}; - public writeValue(value) {} + public writeValue() {} public registerOnChange(fn: any) { this.onChange = fn; } diff --git a/src/app/components/shared/formly/timezone-input.component.spec.ts b/src/app/components/shared/formly/timezone-input.component.spec.ts index 5f6163140..384f04ddd 100644 --- a/src/app/components/shared/formly/timezone-input.component.spec.ts +++ b/src/app/components/shared/formly/timezone-input.component.spec.ts @@ -27,10 +27,6 @@ describe("FormlyTimezoneInput", () => { ], }); - function getInput() { - return spectator.query("input"); - } - function setup(options: FormlyTemplateOptions = {}) { formGroup = new FormGroup({ timezone: new FormControl("") }); model = {}; diff --git a/src/app/components/shared/header/header-item/header-item.component.spec.ts b/src/app/components/shared/header/header-item/header-item.component.spec.ts index 3528eed79..44444687e 100644 --- a/src/app/components/shared/header/header-item/header-item.component.spec.ts +++ b/src/app/components/shared/header/header-item/header-item.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { menuLink, menuRoute } from "@interfaces/menusInterfaces"; import { StrongRoute } from "@interfaces/strongRoute"; diff --git a/src/app/components/shared/header/header.component.spec.ts b/src/app/components/shared/header/header.component.spec.ts index 0697cae6d..5807eb962 100644 --- a/src/app/components/shared/header/header.component.spec.ts +++ b/src/app/components/shared/header/header.component.spec.ts @@ -84,12 +84,6 @@ describe("HeaderComponent", () => { describe(userType.type + " user", () => { let isLoggedIn: boolean; let defaultUser: SessionUser; - const linkIndex = { - project: 0, - listen: 1, - library: 2, - contactUs: 4, - }; function getNavLinks() { return spec.queryAll("a.nav-link"); diff --git a/src/app/components/shared/shared.components.ts b/src/app/components/shared/shared.components.ts index 6ac12f26e..fa92c4d39 100644 --- a/src/app/components/shared/shared.components.ts +++ b/src/app/components/shared/shared.components.ts @@ -9,6 +9,7 @@ import { FormlyModule } from "@ngx-formly/core"; import { LoadingBarHttpClientModule } from "@ngx-loading-bar/http-client"; import { PipesModule } from "@pipes/pipes.module"; import { NgxDatatableModule } from "@swimlane/ngx-datatable"; +import { NgxCaptchaModule } from "ngx-captcha"; import { LineTruncationLibModule } from "ngx-line-truncation"; import { ToastrModule } from "ngx-toastr"; import { DirectivesModule } from "src/app/directives/directives.module"; @@ -75,4 +76,8 @@ export const sharedModules = [ IndicatorModule, ]; -export const internalModules = [...sharedModules, LineTruncationLibModule]; +export const internalModules = [ + ...sharedModules, + LineTruncationLibModule, + NgxCaptchaModule, +]; diff --git a/src/app/components/sites/pages/harvest/point.component.spec.ts b/src/app/components/sites/pages/harvest/point.component.spec.ts index ab0362952..2776c6f69 100644 --- a/src/app/components/sites/pages/harvest/point.component.spec.ts +++ b/src/app/components/sites/pages/harvest/point.component.spec.ts @@ -1,7 +1,4 @@ -import { - HttpClientTestingModule, - HttpTestingController, -} from "@angular/common/http/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; @@ -9,7 +6,6 @@ import { SharedModule } from "@shared/shared.module"; import { PointHarvestComponent } from "./point.component"; describe("PointHarvestComponent", () => { - let httpMock: HttpTestingController; let component: PointHarvestComponent; let fixture: ComponentFixture; @@ -26,7 +22,6 @@ describe("PointHarvestComponent", () => { fixture = TestBed.createComponent(PointHarvestComponent); component = fixture.componentInstance; - httpMock = TestBed.inject(HttpTestingController); fixture.detectChanges(); }); diff --git a/src/app/components/sites/pages/harvest/site.component.spec.ts b/src/app/components/sites/pages/harvest/site.component.spec.ts index 9a358fa27..baf44a67d 100644 --- a/src/app/components/sites/pages/harvest/site.component.spec.ts +++ b/src/app/components/sites/pages/harvest/site.component.spec.ts @@ -1,7 +1,4 @@ -import { - HttpClientTestingModule, - HttpTestingController, -} from "@angular/common/http/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; @@ -9,7 +6,6 @@ import { SharedModule } from "@shared/shared.module"; import { SiteHarvestComponent } from "./site.component"; describe("SiteHarvestComponent", () => { - let httpMock: HttpTestingController; let component: SiteHarvestComponent; let fixture: ComponentFixture; @@ -26,7 +22,6 @@ describe("SiteHarvestComponent", () => { fixture = TestBed.createComponent(SiteHarvestComponent); component = fixture.componentInstance; - httpMock = TestBed.inject(HttpTestingController); fixture.detectChanges(); }); diff --git a/src/app/components/visualize/visualize.menus.ts b/src/app/components/visualize/visualize.menus.ts index bc812af05..f504f3534 100644 --- a/src/app/components/visualize/visualize.menus.ts +++ b/src/app/components/visualize/visualize.menus.ts @@ -4,7 +4,7 @@ import { StrongRoute } from "@interfaces/strongRoute"; export const visualizeRoute = StrongRoute.newRoot().add( "visualize", - ({ siteIds, siteId, regionId, projectId, extent0, extent1, lane }) => { + ({ siteIds, siteId, projectId, extent0, extent1, lane }) => { const qsp = { extent0, extent1, diff --git a/src/app/helpers/formTemplate/formTemplate.ts b/src/app/helpers/formTemplate/formTemplate.ts index 8809fcf55..d57e8be95 100644 --- a/src/app/helpers/formTemplate/formTemplate.ts +++ b/src/app/helpers/formTemplate/formTemplate.ts @@ -6,6 +6,7 @@ import { withFormCheck } from "@guards/form/form.guard"; import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; import { AbstractModel } from "@models/AbstractModel"; import { FormlyFieldConfig } from "@ngx-formly/core"; +import { RecaptchaState } from "@shared/form/form.component"; import { ToastrService } from "ngx-toastr"; import { Observable } from "rxjs"; import { takeUntil } from "rxjs/operators"; @@ -37,6 +38,10 @@ export abstract class FormTemplate * Formly fields */ public fields: FormlyFieldConfig[] = []; + /** + * Recaptcha state tracker, undefined if not used + */ + public recaptchaSeed: RecaptchaState; /** * Success Message */ diff --git a/src/app/helpers/tableTemplate/pagedTableTemplate.ts b/src/app/helpers/tableTemplate/pagedTableTemplate.ts index fe970d5ee..bbfb94912 100644 --- a/src/app/helpers/tableTemplate/pagedTableTemplate.ts +++ b/src/app/helpers/tableTemplate/pagedTableTemplate.ts @@ -79,7 +79,7 @@ export abstract class PagedTableTemplate .subscribe( () => this.getPageData(), // Filter event doesn't have an error output - (err) => {} + (err) => console.error(err) ); } diff --git a/src/app/interfaces/menusInterfaces.ts b/src/app/interfaces/menusInterfaces.ts index f06b42c8b..3bb4af21c 100644 --- a/src/app/interfaces/menusInterfaces.ts +++ b/src/app/interfaces/menusInterfaces.ts @@ -125,7 +125,7 @@ export interface MenuLink extends MenuItem { export function menuLink>(item: T): MenuLink { return Object.assign(item, { - kind: "MenuLink" as "MenuLink", + kind: "MenuLink" as const, active: false, indentation: 0, }); @@ -154,7 +154,7 @@ export function menuRoute>( item: T ): MenuRoute { return Object.assign(item, { - kind: "MenuRoute" as "MenuRoute", + kind: "MenuRoute" as const, active: false, indentation: item.parent ? item.parent.indentation + 1 : 0, order: item.parent?.order ?? item.order, @@ -176,7 +176,7 @@ export function menuAction>( item: T ): MenuAction { return Object.assign(item, { - kind: "MenuAction" as "MenuAction", + kind: "MenuAction" as const, active: false, indentation: 0, }); diff --git a/src/app/interfaces/strongRoute.ts b/src/app/interfaces/strongRoute.ts index 3dd6c3f82..765ae8148 100644 --- a/src/app/interfaces/strongRoute.ts +++ b/src/app/interfaces/strongRoute.ts @@ -214,7 +214,7 @@ export class StrongRoute { if (x.isParameter) { const key = x.pathFragment.substr(1, x.pathFragment.length - 1); - if (params.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(params, key)) { return params[key]; } else { const msg = `Parameter named ${x.pathFragment} was not supplied a value and a default value was not given`; @@ -346,6 +346,7 @@ export class StrongRoute { private rootToHere(): [StrongRoute[], StrongRoute[]] { const fragments = []; const parameters = []; + // eslint-disable-next-line @typescript-eslint/no-this-alias let current: StrongRoute = this; while (isInstantiated(current)) { fragments.push(current); diff --git a/src/app/models/AbstractForm.ts b/src/app/models/AbstractForm.ts new file mode 100644 index 000000000..8ff7cacb0 --- /dev/null +++ b/src/app/models/AbstractForm.ts @@ -0,0 +1,21 @@ +import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; +import { AbstractModel } from "./AbstractModel"; +import { bawPersistAttr } from "./AttributeDecorators"; + +export abstract class AbstractForm< + Model = Record +> extends AbstractModel { + public abstract getBody(token: string): URLSearchParams; + @bawPersistAttr + public readonly recaptchaToken: string; + + public get viewUrl(): string { + throw new Error("Method not implemented"); + } + + protected validateRecaptchaToken() { + if (!isInstantiated(this.recaptchaToken)) { + throw new Error("Unable to retrieve recaptcha token for sign up request"); + } + } +} diff --git a/src/app/models/AbstractModel.ts b/src/app/models/AbstractModel.ts index 716e256f8..488a2222f 100644 --- a/src/app/models/AbstractModel.ts +++ b/src/app/models/AbstractModel.ts @@ -6,8 +6,8 @@ import { Meta } from "../services/baw-api/baw-api.service"; /** * BAW Server Abstract Model */ -export abstract class AbstractModel { - public constructor(raw: Record, protected injector?: Injector) { +export abstract class AbstractModel> { + public constructor(raw: Model, protected injector?: Injector) { return Object.assign(this, raw); } @@ -52,6 +52,7 @@ export abstract class AbstractModel { * * @param args Url arguments */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars public getViewUrl(...args: any[]): string { return this.viewUrl; } diff --git a/src/app/models/AssociationDecorators.ts b/src/app/models/AssociationDecorators.ts index e755a269e..894e61162 100644 --- a/src/app/models/AssociationDecorators.ts +++ b/src/app/models/AssociationDecorators.ts @@ -168,7 +168,7 @@ function createModelDecorator< ): AbstractModel | AbstractModel[] | Readonly { // Check for any backing models const backingFieldKey = "_" + identifierKey; - if (parent.hasOwnProperty(backingFieldKey)) { + if (Object.prototype.hasOwnProperty.call(parent, backingFieldKey)) { return parent[backingFieldKey]; } diff --git a/src/app/models/AudioEvent.ts b/src/app/models/AudioEvent.ts index da46a7ed4..71ce57d36 100644 --- a/src/app/models/AudioEvent.ts +++ b/src/app/models/AudioEvent.ts @@ -33,7 +33,9 @@ export interface IAudioEvent extends HasAllUsers { taggings?: ITagging[] | Tagging[]; } -export class AudioEvent extends AbstractModel implements IAudioEvent { +export class AudioEvent + extends AbstractModel + implements IAudioEvent { public readonly kind = "AudioEvent"; @bawPersistAttr public readonly id?: Id; diff --git a/src/app/models/AudioRecording.ts b/src/app/models/AudioRecording.ts index 063271281..1e6de36eb 100644 --- a/src/app/models/AudioRecording.ts +++ b/src/app/models/AudioRecording.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { ACCOUNT, SHALLOW_SITE } from "@baw-api/ServiceTokens"; import { adminAudioRecordingMenuItem } from "@components/admin/audio-recordings/audio-recordings.menus"; import { listenRecordingMenuItem } from "@components/listen/listen.menus"; @@ -40,7 +39,9 @@ export interface IAudioRecording extends HasAllUsers { /** * An audio recording model */ -export class AudioRecording extends AbstractModel implements IAudioRecording { +export class AudioRecording + extends AbstractModel + implements IAudioRecording { public readonly kind = "AudioRecording"; public readonly id?: Id; public readonly uuid?: Uuid; @@ -83,10 +84,6 @@ export class AudioRecording extends AbstractModel implements IAudioRecording { @hasOne(SHALLOW_SITE, "siteId") public site?: Site; - public constructor(audioRecording: IAudioRecording, injector?: Injector) { - super(audioRecording, injector); - } - public get viewUrl(): string { return listenRecordingMenuItem.route.format({ audioRecordingId: this.id }); } diff --git a/src/app/models/Bookmark.ts b/src/app/models/Bookmark.ts index 8c01d5e81..72a7918fa 100644 --- a/src/app/models/Bookmark.ts +++ b/src/app/models/Bookmark.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { AUDIO_RECORDING } from "@baw-api/ServiceTokens"; import { listenRecordingMenuItem } from "@components/listen/listen.menus"; import { @@ -26,7 +25,7 @@ export interface IBookmark extends HasCreatorAndUpdater, HasDescription { category?: string; } -export class Bookmark extends AbstractModel implements IBookmark { +export class Bookmark extends AbstractModel implements IBookmark { public readonly kind = "Bookmark"; @bawPersistAttr public readonly id?: Id; @@ -57,10 +56,6 @@ export class Bookmark extends AbstractModel implements IBookmark { @hasOne(AUDIO_RECORDING, "audioRecordingId") public audioRecording?: AudioRecording; - public constructor(bookmark: IBookmark, injector?: Injector) { - super(bookmark, injector); - } - public get viewUrl(): string { return listenRecordingMenuItem.route.format( { audioRecordingId: this.audioRecordingId }, diff --git a/src/app/models/Dataset.ts b/src/app/models/Dataset.ts index 455b2b8de..bcf3c0aed 100644 --- a/src/app/models/Dataset.ts +++ b/src/app/models/Dataset.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { DateTimeTimezone, Description, @@ -17,7 +16,7 @@ export interface IDataset extends HasCreatorAndUpdater, HasDescription { name?: Param; } -export class Dataset extends AbstractModel implements IDataset { +export class Dataset extends AbstractModel implements IDataset { public readonly kind = "Dataset"; @bawPersistAttr public readonly id?: Id; @@ -40,10 +39,6 @@ export class Dataset extends AbstractModel implements IDataset { @updater() public updater?: User; - public constructor(dataset: IDataset, injector?: Injector) { - super(dataset, injector); - } - public get viewUrl(): string { throw new Error("Dataset viewUrl not implemented."); } diff --git a/src/app/models/DatasetItem.ts b/src/app/models/DatasetItem.ts index ea281121c..a79895e43 100644 --- a/src/app/models/DatasetItem.ts +++ b/src/app/models/DatasetItem.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { AUDIO_RECORDING, DATASET } from "@baw-api/ServiceTokens"; import { DateTimeTimezone, Id } from "@interfaces/apiInterfaces"; import { AbstractModel } from "./AbstractModel"; @@ -19,7 +18,9 @@ export interface IDatasetItem { order?: number; } -export class DatasetItem extends AbstractModel implements IDatasetItem { +export class DatasetItem + extends AbstractModel + implements IDatasetItem { public readonly kind = "DatasetItem"; @bawPersistAttr public readonly id?: Id; @@ -45,10 +46,6 @@ export class DatasetItem extends AbstractModel implements IDatasetItem { @hasOne(AUDIO_RECORDING, "audioRecordingId") public audioRecording?: AudioRecording; - public constructor(datasetItem: IDatasetItem, injector?: Injector) { - super(datasetItem, injector); - } - public get viewUrl(): string { throw new Error("DatasetItem viewUrl not implemented."); } diff --git a/src/app/models/ProgressEvent.ts b/src/app/models/ProgressEvent.ts index b3f76544d..c3f317c64 100644 --- a/src/app/models/ProgressEvent.ts +++ b/src/app/models/ProgressEvent.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { DateTimeTimezone, HasCreator, Id } from "@interfaces/apiInterfaces"; import { AbstractModel } from "./AbstractModel"; import { creator } from "./AssociationDecorators"; @@ -11,7 +10,9 @@ export interface IProgressEvent extends HasCreator { activity?: string; } -export class ProgressEvent extends AbstractModel implements IProgressEvent { +export class ProgressEvent + extends AbstractModel + implements IProgressEvent { public readonly kind = "ProgressEvent"; @bawPersistAttr public readonly id?: Id; @@ -28,10 +29,6 @@ export class ProgressEvent extends AbstractModel implements IProgressEvent { public creator?: User; // TODO Add association to DatasetItem - public constructor(progressEvent: IProgressEvent, injector?: Injector) { - super(progressEvent, injector); - } - public get viewUrl(): string { throw new Error("ProgressEvent viewUrl not implemented."); } diff --git a/src/app/models/Project.ts b/src/app/models/Project.ts index b8bd51a4b..baa594095 100644 --- a/src/app/models/Project.ts +++ b/src/app/models/Project.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { ACCOUNT, SHALLOW_REGION, SHALLOW_SITE } from "@baw-api/ServiceTokens"; import { projectMenuItem } from "@components/projects/projects.menus"; import { @@ -50,7 +49,7 @@ export interface IProject extends HasAllUsers, HasDescription { /** * A project model. */ -export class Project extends AbstractModel implements IProject { +export class Project extends AbstractModel implements IProject { public readonly kind = "Project"; @bawPersistAttr public readonly id?: Id; @@ -97,10 +96,6 @@ export class Project extends AbstractModel implements IProject { @deleter() public deleter?: User; - public constructor(project: IProject, injector?: Injector) { - super(project, injector); - } - /** * Generate card-item details */ diff --git a/src/app/models/Question.ts b/src/app/models/Question.ts index d02a57929..069ed6da3 100644 --- a/src/app/models/Question.ts +++ b/src/app/models/Question.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { DateTimeTimezone, Id } from "@interfaces/apiInterfaces"; import { AbstractModel } from "./AbstractModel"; import { creator, updater } from "./AssociationDecorators"; @@ -15,7 +14,7 @@ export interface IQuestion { updatedAt?: DateTimeTimezone | string; } -export class Question extends AbstractModel implements IQuestion { +export class Question extends AbstractModel implements IQuestion { public readonly kind = "Question"; @bawPersistAttr public readonly id?: Id; @@ -36,10 +35,6 @@ export class Question extends AbstractModel implements IQuestion { @updater() public updater?: User; - public constructor(question: IQuestion, injector?: Injector) { - super(question, injector); - } - public get viewUrl(): string { throw new Error("Question viewUrl not implemented."); } diff --git a/src/app/models/Region.ts b/src/app/models/Region.ts index 65a143ca5..3eedd4521 100644 --- a/src/app/models/Region.ts +++ b/src/app/models/Region.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { PROJECT, SHALLOW_SITE } from "@baw-api/ServiceTokens"; import { regionMenuItem } from "@components/regions/regions.menus"; import { visualizeMenuItem } from "@components/visualize/visualize.menus"; @@ -46,7 +45,7 @@ export interface IRegion extends HasAllUsers, HasDescription { /** * A region model. */ -export class Region extends AbstractModel implements IRegion { +export class Region extends AbstractModel implements IRegion { public readonly kind = "Region"; @bawPersistAttr public readonly id?: Id; @@ -90,10 +89,6 @@ export class Region extends AbstractModel implements IRegion { @deleter() public deleter?: User; - public constructor(region: IRegion, injector?: Injector) { - super(region, injector); - } - public get viewUrl(): string { return regionMenuItem.route.format({ projectId: this.projectId, diff --git a/src/app/models/Response.ts b/src/app/models/Response.ts index c45e54858..c006b701b 100644 --- a/src/app/models/Response.ts +++ b/src/app/models/Response.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { SHALLOW_QUESTION, STUDY } from "@baw-api/ServiceTokens"; import { DateTimeTimezone, Id } from "@interfaces/apiInterfaces"; import { AbstractModel } from "./AbstractModel"; @@ -18,7 +17,7 @@ export interface IResponse { createdAt?: DateTimeTimezone | string; } -export class Response extends AbstractModel implements IResponse { +export class Response extends AbstractModel implements IResponse { public readonly kind = "Answer"; @bawPersistAttr public readonly id?: Id; @@ -43,10 +42,6 @@ export class Response extends AbstractModel implements IResponse { @hasOne(STUDY, "studyId") public study?: Study; - public constructor(question: IResponse, injector?: Injector) { - super(question, injector); - } - public get viewUrl(): string { throw new Error("Response viewUrl not implemented."); } diff --git a/src/app/models/SavedSearch.ts b/src/app/models/SavedSearch.ts index 380ff12cb..52b22214b 100644 --- a/src/app/models/SavedSearch.ts +++ b/src/app/models/SavedSearch.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { InnerFilter } from "@baw-api/baw-api.service"; import { DateTimeTimezone, @@ -20,7 +19,9 @@ export interface ISavedSearch extends HasCreatorAndDeleter, HasDescription { storedQuery?: InnerFilter; } -export class SavedSearch extends AbstractModel implements ISavedSearch { +export class SavedSearch + extends AbstractModel + implements ISavedSearch { public readonly kind = "Saved Search"; @bawPersistAttr public readonly id?: Id; @@ -45,10 +46,6 @@ export class SavedSearch extends AbstractModel implements ISavedSearch { @deleter() public deleter?: User; - public constructor(savedSearches: ISavedSearch, injector?: Injector) { - super(savedSearches, injector); - } - public get viewUrl(): string { throw new Error("SavedSearch viewUrl not implemented."); } diff --git a/src/app/models/Script.ts b/src/app/models/Script.ts index e85f6c471..0157f5808 100644 --- a/src/app/models/Script.ts +++ b/src/app/models/Script.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { SCRIPT } from "@baw-api/ServiceTokens"; import { adminScriptMenuItem } from "@components/admin/scripts/scripts.menus"; import { @@ -31,7 +30,7 @@ export interface IScript extends HasCreator, HasDescription { analysisActionParams?: Hash; } -export class Script extends AbstractModel implements IScript { +export class Script extends AbstractModel implements IScript { public readonly kind = "Script"; @bawPersistAttr public readonly id?: Id; @@ -66,10 +65,6 @@ export class Script extends AbstractModel implements IScript { @hasOne(SCRIPT, "groupId") public group?: Script; - public constructor(script: IScript, injector?: Injector) { - super(script, injector); - } - public get viewUrl(): string { throw new Error("Script viewUrl not implemented."); } diff --git a/src/app/models/Site.ts b/src/app/models/Site.ts index e9b50cae7..0f4ac0096 100644 --- a/src/app/models/Site.ts +++ b/src/app/models/Site.ts @@ -54,7 +54,7 @@ export interface ISite extends HasAllUsers, HasDescription { /** * A site model. */ -export class Site extends AbstractModel implements ISite { +export class Site extends AbstractModel implements ISite { public readonly kind = "Site"; @bawPersistAttr public readonly id?: Id; diff --git a/src/app/models/Study.ts b/src/app/models/Study.ts index bdab080f8..8dc58c47e 100644 --- a/src/app/models/Study.ts +++ b/src/app/models/Study.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { DATASET } from "@baw-api/ServiceTokens"; import { citSciAboutMenuItem } from "@components/citizen-science/citizen-science.menus"; import { @@ -19,7 +18,7 @@ export interface IStudy extends HasCreatorAndUpdater { datasetId?: Id; } -export class Study extends AbstractModel implements IStudy { +export class Study extends AbstractModel implements IStudy { public readonly kind = "Studies"; @bawPersistAttr public readonly id?: Id; @@ -42,10 +41,6 @@ export class Study extends AbstractModel implements IStudy { @hasOne(DATASET, "datasetId") public dataset?: Dataset; - public constructor(study: IStudy, injector?: Injector) { - super(study, injector); - } - public get viewUrl(): string { return citSciAboutMenuItem.route.format({ studyName: this.name }); } diff --git a/src/app/models/Tag.ts b/src/app/models/Tag.ts index ffbc8c6eb..43d1babb5 100644 --- a/src/app/models/Tag.ts +++ b/src/app/models/Tag.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { libraryMenuItem } from "@components/library/library.menus"; import { DateTimeTimezone, @@ -27,7 +26,7 @@ export interface ITag extends HasCreatorAndUpdater { /** * Tag model */ -export class Tag extends AbstractModel implements ITag { +export class Tag extends AbstractModel implements ITag { public readonly kind = "Tag"; @bawPersistAttr public readonly id?: Id; @@ -54,10 +53,6 @@ export class Tag extends AbstractModel implements ITag { @updater() public updater?: User; - public constructor(tag: ITag, injector?: Injector) { - super(tag, injector); - } - public get viewUrl(): string { return libraryMenuItem.route.format(undefined, { reference: "all", diff --git a/src/app/models/TagGroup.ts b/src/app/models/TagGroup.ts index c8557a036..c9822cb64 100644 --- a/src/app/models/TagGroup.ts +++ b/src/app/models/TagGroup.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { TAG } from "@baw-api/ServiceTokens"; import { adminTagGroupsMenuItem } from "@components/admin/tag-group/tag-group.menus"; import { DateTimeTimezone, HasCreator, Id } from "@interfaces/apiInterfaces"; @@ -20,7 +19,7 @@ export interface ITagGroup extends HasCreator { /** * A tag group model */ -export class TagGroup extends AbstractModel implements ITagGroup { +export class TagGroup extends AbstractModel implements ITagGroup { public readonly kind = "TagGroup"; @bawPersistAttr public readonly id?: Id; @@ -38,10 +37,6 @@ export class TagGroup extends AbstractModel implements ITagGroup { @hasOne(TAG, "tagId") public tag?: Tag; - public constructor(tagGroup: ITagGroup, injector?: Injector) { - super(tagGroup, injector); - } - public get viewUrl(): string { throw Error("TagGroup viewUrl not implemented"); } diff --git a/src/app/models/Tagging.ts b/src/app/models/Tagging.ts index f17c219c8..6095b8afe 100644 --- a/src/app/models/Tagging.ts +++ b/src/app/models/Tagging.ts @@ -1,4 +1,3 @@ -import { Injector } from "@angular/core"; import { TAG } from "@baw-api/ServiceTokens"; import { DateTimeTimezone, @@ -17,7 +16,7 @@ export interface ITagging extends HasCreatorAndUpdater { tagId?: Id; } -export class Tagging extends AbstractModel implements ITagging { +export class Tagging extends AbstractModel implements ITagging { public readonly kind = "Tagging"; @bawPersistAttr public readonly id?: Id; @@ -41,10 +40,6 @@ export class Tagging extends AbstractModel implements ITagging { @hasOne(TAG, "tagId") public tag?: Tag; - public constructor(tagging: ITagging, injector?: Injector) { - super(tagging, injector); - } - public get viewUrl(): string { throw Error("Tagging viewUrl not implemented"); } diff --git a/src/app/models/User.ts b/src/app/models/User.ts index a8aa1f5e9..809555064 100644 --- a/src/app/models/User.ts +++ b/src/app/models/User.ts @@ -1,3 +1,4 @@ +import { Injector } from "@angular/core"; import { assetRoot } from "@services/config/config.service"; import { myAccountMenuItem, @@ -48,7 +49,7 @@ export interface IUser { /** * A user model. */ -export class User extends AbstractModel implements IUser { +export class User extends AbstractModel implements IUser { /** * Deleted User. This is used when a user has been soft deleted * from the API. @@ -117,8 +118,8 @@ export class User extends AbstractModel implements IUser { @bawDateTime() public readonly lastSeenAt?: DateTimeTimezone; - public constructor(user: IUser) { - super(user); + public constructor(user: IUser, injector?: Injector) { + super(user, injector); this.tzinfoTz = this.tzinfoTz ?? this.timezoneInformation?.identifier; } @@ -145,7 +146,9 @@ export interface ISessionUser extends IUser { /** * A user model for the website user */ -export class SessionUser extends AbstractModel implements ISessionUser { +export class SessionUser + extends AbstractModel + implements ISessionUser { // ! All fields are persisted because model is saved to, and read from, localstorage public readonly kind = "SessionUser"; @bawPersistAttr @@ -169,9 +172,8 @@ export class SessionUser extends AbstractModel implements ISessionUser { @bawPersistAttr public readonly timezoneInformation?: TimezoneInformation; - public constructor(user: ISessionUser & Partial) { - super(user); - + public constructor(user: ISessionUser & Partial, injector?: Injector) { + super(user, injector); this.tzinfoTz = this.tzinfoTz ?? this.timezoneInformation?.identifier; } diff --git a/src/app/pipes/isGhostUser/is-ghost-user.pipe.spec.ts b/src/app/pipes/isGhostUser/is-ghost-user.pipe.spec.ts index 991cd39bf..a3c164bd3 100644 --- a/src/app/pipes/isGhostUser/is-ghost-user.pipe.spec.ts +++ b/src/app/pipes/isGhostUser/is-ghost-user.pipe.spec.ts @@ -23,17 +23,17 @@ describe("IsGhostUserPipe", () => { [ { - type: "unknown" as "unknown", + type: "unknown" as const, unknown: true, deleted: false, }, { - type: "deleted" as "deleted", + type: "deleted" as const, unknown: false, deleted: true, }, { - type: "all" as "all", + type: "all" as const, unknown: true, deleted: true, }, diff --git a/src/app/services/baw-api/ServiceProviders.ts b/src/app/services/baw-api/ServiceProviders.ts index 2e134e811..e27863977 100644 --- a/src/app/services/baw-api/ServiceProviders.ts +++ b/src/app/services/baw-api/ServiceProviders.ts @@ -36,6 +36,8 @@ import { shallowRegionResolvers, ShallowRegionsService, } from "./region/regions.service"; +import { ContactUsService } from "./report/contact-us.service"; +import { ReportProblemService } from "./report/report-problem.service"; import { BawProvider } from "./resolver-common"; import { SavedSearchesService, @@ -91,7 +93,6 @@ const serviceList = [ { serviceToken: Tokens.SHALLOW_AUDIO_EVENT, service: ShallowAudioEventsService, - resolvers: undefined, }, { serviceToken: Tokens.AUDIO_RECORDING, @@ -103,6 +104,10 @@ const serviceList = [ service: BookmarksService, resolvers: bookmarkResolvers, }, + { + serviceToken: Tokens.CONTACT_US, + service: ContactUsService, + }, { serviceToken: Tokens.DATASET, service: DatasetsService, @@ -143,6 +148,10 @@ const serviceList = [ service: ShallowRegionsService, resolvers: shallowRegionResolvers, }, + { + serviceToken: Tokens.REPORT_PROBLEM, + service: ReportProblemService, + }, { serviceToken: Tokens.RESPONSE, service: ResponsesService, diff --git a/src/app/services/baw-api/ServiceTokens.ts b/src/app/services/baw-api/ServiceTokens.ts index 46188c3d4..9cf607ccb 100644 --- a/src/app/services/baw-api/ServiceTokens.ts +++ b/src/app/services/baw-api/ServiceTokens.ts @@ -44,6 +44,11 @@ import type { RegionsService, ShallowRegionsService, } from "./region/regions.service"; +import type { ContactUs, ContactUsService } from "./report/contact-us.service"; +import type { + ReportProblem, + ReportProblemService, +} from "./report/report-problem.service"; import type { SavedSearchesService } from "./saved-search/saved-searches.service"; import type { ScriptsService } from "./script/scripts.service"; import type { ShallowSitesService, SitesService } from "./site/sites.service"; @@ -100,6 +105,9 @@ export const AUDIO_RECORDING = new ServiceToken< AudioRecordingsService, AudioRecording >("RECORDING"); +export const CONTACT_US = new ServiceToken( + "CONTACT_US" +); export const BOOKMARK = new ServiceToken( "BOOKMARK" ); @@ -123,6 +131,10 @@ export const REGION = new ServiceToken("REGION"); export const SHALLOW_REGION = new ServiceToken( "S_REGION" ); +export const REPORT_PROBLEM = new ServiceToken< + ReportProblemService, + ReportProblem +>("REPORT_PROBLEM"); export const RESPONSE = new ServiceToken( "RESPONSE" ); diff --git a/src/app/services/baw-api/account/accounts.service.ts b/src/app/services/baw-api/account/accounts.service.ts index 7af26e501..4f5fcccaa 100644 --- a/src/app/services/baw-api/account/accounts.service.ts +++ b/src/app/services/baw-api/account/accounts.service.ts @@ -4,6 +4,7 @@ import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; import { API_ROOT } from "@helpers/app-initializer/app-initializer"; import { stringTemplate } from "@helpers/stringTemplate/stringTemplate"; import { IUser, User } from "@models/User"; +import httpCodes from "http-status"; import { Observable, of, throwError } from "rxjs"; import { catchError } from "rxjs/operators"; import { @@ -15,7 +16,7 @@ import { option, StandardApi, } from "../api-common"; -import { apiReturnCodes, Filters } from "../baw-api.service"; +import { Filters } from "../baw-api.service"; import { Resolvers } from "../resolver-common"; const userId: IdParamOptional = id; @@ -46,10 +47,10 @@ export class AccountsService extends StandardApi { // Return unknown or deleted user depending on error code catchError((err: ApiErrorDetails) => { switch (err.status) { - case apiReturnCodes.unauthorized: + case httpCodes.UNAUTHORIZED: // Return unknown user, this only occurs when user is anonymous to the logged in/guest user return of(User.unknownUser); - case apiReturnCodes.notFound: + case httpCodes.NOT_FOUND: // Return deleted user, this only occurs when a user is soft-deleted return of(User.deletedUser); default: diff --git a/src/app/services/baw-api/api.interceptor.service.spec.ts b/src/app/services/baw-api/api.interceptor.service.spec.ts index a5957d592..eeb3db814 100644 --- a/src/app/services/baw-api/api.interceptor.service.spec.ts +++ b/src/app/services/baw-api/api.interceptor.service.spec.ts @@ -11,21 +11,15 @@ import { HttpMethod, SpectatorHttp, } from "@ngneat/spectator"; -import { ConfigService } from "@services/config/config.service"; import { generateApiErrorResponse } from "@test/fakes/ApiErrorDetails"; import { generateSessionUser } from "@test/fakes/User"; import { noop } from "rxjs"; import { ApiResponse } from "./baw-api.service"; -import { - apiErrorInfoDetails, - shouldNotFail, - shouldNotSucceed, -} from "./baw-api.service.spec"; +import { shouldNotFail, shouldNotSucceed } from "./baw-api.service.spec"; describe("BawApiInterceptor", () => { let apiRoot: string; let http: HttpClient; - let config: ConfigService; let spec: SpectatorHttp; const createService = createHttpFactory({ service: SecurityService, @@ -48,7 +42,6 @@ describe("BawApiInterceptor", () => { beforeEach(() => { spec = createService(); http = spec.inject(HttpClient); - config = spec.inject(ConfigService); apiRoot = spec.inject(API_ROOT); }); @@ -82,7 +75,7 @@ describe("BawApiInterceptor", () => { it("should handle api error response with info", () => { const error = generateApiErrorResponse("Unprocessable Entity", { - info: apiErrorInfoDetails.info, + info: { name: ["has already been taken"] }, }); http diff --git a/src/app/services/baw-api/baw-api.service.spec.ts b/src/app/services/baw-api/baw-api.service.spec.ts index 4f0935d77..470d8eba8 100644 --- a/src/app/services/baw-api/baw-api.service.spec.ts +++ b/src/app/services/baw-api/baw-api.service.spec.ts @@ -1,11 +1,6 @@ import { HTTP_INTERCEPTORS } from "@angular/common/http"; -import { - HttpClientTestingModule, - HttpTestingController, - TestRequest, -} from "@angular/common/http/testing"; +import { TestRequest } from "@angular/common/http/testing"; import { Injector } from "@angular/core"; -import { TestBed } from "@angular/core/testing"; import { ApiErrorDetails, BawApiInterceptor, @@ -19,129 +14,106 @@ import { import { MockSecurityService } from "@baw-api/mock/securityMock.service"; import { SecurityService } from "@baw-api/security/security.service"; import { UserService } from "@baw-api/user/user.service"; +import { API_ROOT } from "@helpers/app-initializer/app-initializer"; import { AbstractModel, getUnknownViewUrl } from "@models/AbstractModel"; import { SessionUser } from "@models/User"; -import { ConfigService } from "@services/config/config.service"; +import { + createHttpFactory, + HttpMethod, + SpectatorHttp, +} from "@ngneat/spectator"; import { MockAppConfigModule } from "@services/config/configMock.module"; -import { generateApiErrorDetails } from "@test/fakes/ApiErrorDetails"; -import { BehaviorSubject, Subject } from "rxjs"; +import { generateSessionUser } from "@test/fakes/User"; +import { BehaviorSubject, noop, Observable, Subject } from "rxjs"; import { MockShowApiService } from "./mock/apiMocks.service"; export const shouldNotSucceed = () => { fail("Service should not produce a data output"); }; - export const shouldNotFail = () => { fail("Service should not produce an error"); }; - export const shouldNotComplete = () => { fail("Service should not complete"); }; -export const apiErrorDetails = generateApiErrorDetails("Unauthorized"); - -export const apiErrorInfoDetails = generateApiErrorDetails( - "Unprocessable Entity", - { - message: "Record could not be saved", - info: { - name: ["has already been taken"], - image: [], - imageFileName: [], - imageFileSize: [], - imageContentType: [], - imageUpdatedAt: [], - }, - } -); +class IMockModel { + public id?: number; + public name?: string; + public caseConversion?: { + testConvert?: string; + }; +} -describe("BawApiService", () => { - /** - * Mock model interface - */ - class MockModelInterface { - public id?: number; - public name?: string; - public caseConversion?: { - testConvert?: string; - }; +class MockModel extends AbstractModel { + public readonly id?: number; + public readonly name?: string; + public readonly caseConversion?: { + testConvert?: string; + }; + + public constructor(data: IMockModel, modelInjector: Injector) { + super(data, modelInjector); } - /** - * Mock model to simulate abstract model - */ - class MockModel extends AbstractModel { - public readonly id?: number; - public readonly name?: string; - public readonly caseConversion?: { - testConvert?: string; + public toJSON() { + return { + id: this.id, + name: this.name, + caseConversion: this.caseConversion, }; - - public constructor(data: MockModelInterface, modelInjector: Injector) { - super(data, modelInjector); - } - - public toJSON() { - return { - id: this.id, - name: this.name, - caseConversion: this.caseConversion, - }; - } - - public get viewUrl(): string { - return getUnknownViewUrl("MockModel does not have a viewUrl"); - } } - let service: BawApiService; - let config: ConfigService; - let httpMock: HttpTestingController; - - // Multi response metadata - let multiMeta: Meta; - - // Single response metadata - const singleMeta: Meta = { status: 200, message: "OK" }; - - // Api error metadata - const errorMeta: Meta = { - status: apiErrorDetails.status, - message: "Unauthorized", - error: { details: apiErrorDetails.message }, - }; + public get viewUrl(): string { + return getUnknownViewUrl("MockModel does not have a viewUrl"); + } +} - // Api error metadata with info - const errorInfoMeta: Meta = { - status: apiErrorInfoDetails.status, - message: "Unprocessable Entity", - error: { - details: apiErrorInfoDetails.message, - info: { - name: ["has already been taken"], - image: [], - imageFileName: [], - imageFileSize: [], - imageContentType: [], - imageUpdatedAt: [], - }, - }, +describe("BawApiService", () => { + let meta: { + /** Single model response */ + single: Meta; + /** Array response */ + multi: Meta; + /** Basic error response */ + error: Meta; + /** Extended error response */ + errorInfo: Meta; }; - // Single model response - const singleResponse = { - id: 1, - name: "name", - caseConversion: { testConvert: "converted" }, + let responses: { + /** Single model */ + single: IMockModel; + /** Multiple models */ + multi: IMockModel[]; + /** Basic error */ + error: ApiErrorDetails; + /** Extended error */ + errorInfo: ApiErrorDetails; }; - // Multi model response - const multiResponse = [singleResponse]; + let sessionUser: SessionUser; + let apiRoot: string; + let service: BawApiService; + let spec: SpectatorHttp>; + const createService = createHttpFactory>({ + service: BawApiService, + imports: [MockAppConfigModule], + providers: [ + BawApiService, + { provide: SecurityService, useClass: MockSecurityService }, + { provide: UserService, useClass: MockShowApiService }, + { + provide: HTTP_INTERCEPTORS, + useClass: BawApiInterceptor, + multi: true, + }, + { provide: STUB_MODEL_BUILDER, useValue: MockModel }, + ], + }); - function signIn(authToken: string, userName: string) { - const sessionUser = new SessionUser({ id: 1, authToken, userName }); - localStorage.setItem("baw.client.user", JSON.stringify(sessionUser)); + function signIn(_sessionUser: SessionUser) { + localStorage.setItem("baw.client.user", JSON.stringify(_sessionUser)); } function signOut() { @@ -155,72 +127,80 @@ describe("BawApiService", () => { }); } - function catchRequest(route: string, method: string) { - return httpMock.expectOne({ - url: config.environment.apiRoot + route, - method, - }); + function catchRequest(route: string, method: HttpMethod) { + return spec.expectOne(apiRoot + route, method); } function verifyHeaders(req: TestRequest) { - expect(req.request.headers.has("Accept")).toBeTruthy( - "Request should contain Accept Headers" + const headers = req.request.headers; + expect(headers.get("Accept")).toBe( + "application/json", + "Request should contain Accept Headers set to 'application/json'" ); - expect(req.request.headers.get("Accept")).toBe("application/json"); - expect(req.request.headers.has("Content-Type")).toBeTruthy( - "Request should contain Content-Type Headers" + expect(headers.get("Content-Type")).toBe( + "application/json", + "Request should contain Content-Type Headers set to 'application/json" ); - expect(req.request.headers.get("Content-Type")).toBe("application/json"); } function verifyAuthHeaders(req: TestRequest, authToken: string) { verifyHeaders(req); - expect(req.request.headers.has("Authorization")).toBeTruthy(); expect(req.request.headers.get("Authorization")).toBe( - `Token token="${authToken}"` + `Token token="${authToken}"`, + "Request should container Authorization Header with token set" ); } beforeEach(() => { localStorage.clear(); - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, MockAppConfigModule], - providers: [ - BawApiService, - { provide: SecurityService, useClass: MockSecurityService }, - { provide: UserService, useClass: MockShowApiService }, - { - provide: HTTP_INTERCEPTORS, - useClass: BawApiInterceptor, - multi: true, + spec = createService(); + service = spec.service; + apiRoot = spec.inject(API_ROOT); + sessionUser = new SessionUser(generateSessionUser()); + + const successMeta = { status: 200, message: "OK" }; + meta = { + single: successMeta, + multi: { + ...successMeta, + sorting: { orderBy: "name", direction: "asc" }, + paging: { page: 1, items: 1, total: 1, maxPage: 1 }, + }, + error: { + status: 401, + message: "Unauthorized", + error: { details: "Unauthorized Access", info: undefined }, + }, + errorInfo: { + status: 422, + message: "Unprocessable Entity", + error: { + details: "Record could not be saved", + info: { name: ["has already been taken"], image: [] }, }, - { provide: STUB_MODEL_BUILDER, useValue: MockModel }, - ], - }); - service = TestBed.inject>(BawApiService); - config = TestBed.inject(ConfigService); - httpMock = TestBed.inject(HttpTestingController); - - multiMeta = { - status: 200, - message: "OK", - sorting: { - orderBy: "name", - direction: "asc", }, - paging: { - page: 1, - items: 1, - total: 1, - maxPage: 1, + }; + + const modelData = { + id: 1, + name: "name", + caseConversion: { testConvert: "converted" }, + }; + responses = { + single: modelData, + multi: [modelData], + error: { status: 401, message: "Unauthorized Access", info: undefined }, + errorInfo: { + status: 422, + message: "Record could not be saved", + info: { name: ["has already been taken"], image: [] }, }, }; }); afterEach(() => { localStorage.clear(); - httpMock.verify(); + spec.controller.verify(); }); describe("Session Tracking", () => { @@ -237,24 +217,24 @@ describe("BawApiService", () => { }); it("should be logged in after user saved to local storage", () => { - signIn("xxxxxxxxxxxxxxx", "username"); + signIn(sessionUser); expect(service.isLoggedIn()).toBeTruthy(); }); it("should return user after user saved to local storage", () => { - signIn("xxxxxxxxxxxxxxx", "username"); - expect(service.getLocalUser().authToken).toBe("xxxxxxxxxxxxxxx"); - expect(service.getLocalUser().userName).toBe("username"); + signIn(sessionUser); + expect(service.getLocalUser().authToken).toBe(sessionUser.authToken); + expect(service.getLocalUser().userName).toBe(sessionUser.userName); }); it("should not be logged in after user removed from local storage", () => { - signIn("xxxxxxxxxxxxxxx", "username"); + signIn(sessionUser); signOut(); expect(service.isLoggedIn()).toBe(false); }); it("should not return user after user removed from local storage", () => { - signIn("xxxxxxxxxxxxxxx", "username"); + signIn(sessionUser); signOut(); expect(service.getLocalUser()).toBe(undefined); }); @@ -268,130 +248,101 @@ describe("BawApiService", () => { describe("HTTP Request Methods", () => { const httpMethods = [ - { - functionName: "httpGet", - method: "GET", - hasBody: false, - }, - { - functionName: "httpPost", - method: "POST", - hasBody: true, - }, - { - functionName: "httpPatch", - method: "PATCH", - hasBody: true, - }, - { - functionName: "httpDelete", - method: "DELETE", - hasBody: false, - }, + { functionName: "httpGet", method: HttpMethod.GET, hasBody: false }, + { functionName: "httpPost", method: HttpMethod.POST, hasBody: true }, + { functionName: "httpPatch", method: HttpMethod.PATCH, hasBody: true }, + { functionName: "httpDelete", method: HttpMethod.DELETE, hasBody: false }, ]; httpMethods.forEach((httpMethod) => { describe(httpMethod.functionName, () => { + function functionCall( + opts: (string | number)[] = [], + next: (value: any) => void = noop, + error: (err: any) => void = noop, + complete: () => void = noop + ): void { + (service[httpMethod.functionName]( + "/broken_link", + ...opts + ) as Observable>).subscribe({ + next, + error, + complete, + }); + } + + function catchFunctionCall() { + return catchRequest("/broken_link", httpMethod.method); + } + it(`should create ${httpMethod.method} request`, () => { - service[httpMethod.functionName]("/broken_link").subscribe(); - const req = catchRequest("/broken_link", httpMethod.method); - expect(req).toBeTruthy(); + functionCall(); + expect(catchFunctionCall()).toBeTruthy(); }); it("should create request with baw server headers", () => { - service[httpMethod.functionName]("/broken_link").subscribe(); - const req = catchRequest("/broken_link", httpMethod.method); - verifyHeaders(req); + functionCall(); + verifyHeaders(catchFunctionCall()); }); it("should create request with authenticated baw server headers", () => { - signIn("xxxxxxxxxxxxxxx", "username"); - service[httpMethod.functionName]("/broken_link").subscribe(); - const req = catchRequest("/broken_link", httpMethod.method); - verifyAuthHeaders(req, "xxxxxxxxxxxxxxx"); + signIn(sessionUser); + functionCall(); + verifyAuthHeaders(catchFunctionCall(), sessionUser.authToken); }); - it("should return single response", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - - service[httpMethod.functionName]("/broken_link", {}).subscribe( - (data: ApiResponse) => expect(data).toEqual(response), + it("should return single response", (done) => { + const response = { meta: meta.single, data: responses.single }; + functionCall( + undefined, + (data) => { + expect(data).toEqual(response); + done(); + }, shouldNotFail ); - - const req = catchRequest("/broken_link", httpMethod.method); - flushResponse(req, response); + flushResponse(catchFunctionCall(), response); }); - it("should return multi response", () => { - const response = { - meta: singleMeta, - data: [singleResponse], - } as ApiResponse; - - service[httpMethod.functionName]("/broken_link", {}).subscribe( - (data: ApiResponse) => expect(data).toEqual(response), + it("should return multi response", (done) => { + const response = { meta: meta.multi, data: responses.multi }; + functionCall( + undefined, + (data) => { + expect(data).toEqual(response); + done(); + }, shouldNotFail ); - - const req = catchRequest("/broken_link", httpMethod.method); - flushResponse(req, response); + flushResponse(catchFunctionCall(), response); }); - it("should handle error response", () => { - const response = { - meta: errorMeta, - data: null, - } as ApiResponse; - - service[httpMethod.functionName]( - "/broken_link", - {} - ).subscribe(shouldNotSucceed, (err: ApiErrorDetails) => - expect(err).toEqual(apiErrorDetails) - ); - - const req = catchRequest("/broken_link", httpMethod.method); - flushResponse(req, response); + it("should handle error response", (done) => { + const response = { meta: meta.error, data: null }; + functionCall(undefined, shouldNotSucceed, (err) => { + expect(err).toEqual(responses.error); + done(); + }); + flushResponse(catchFunctionCall(), response); }); - it("should handle error response with info", () => { - const response = { - meta: errorInfoMeta, - data: null, - } as ApiResponse; - - service[httpMethod.functionName]( - "/broken_link", - {} - ).subscribe(shouldNotSucceed, (err: ApiErrorDetails) => - expect(err).toEqual(apiErrorInfoDetails) - ); - - const req = catchRequest("/broken_link", httpMethod.method); - flushResponse(req, response); + it("should handle error response with info", (done) => { + const response = { meta: meta.errorInfo, data: null }; + functionCall(undefined, shouldNotSucceed, (err) => { + expect(err).toEqual(responses.errorInfo); + done(); + }); + flushResponse(catchFunctionCall(), response); }); it("should complete on success", (done) => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - - service[httpMethod.functionName]("/broken_link", {}).subscribe( - () => {}, - shouldNotFail, - () => { - expect(true).toBeTruthy(); - done(); - } - ); - - const req = catchRequest("/broken_link", httpMethod.method); - flushResponse(req, response); + const response = { meta: meta.single, data: responses.single }; + functionCall(undefined, noop, noop, () => { + expect(true).toBeTruthy(); + done(); + }); + flushResponse(catchFunctionCall(), response); }); // If http method can accept body inputs @@ -437,520 +388,173 @@ describe("BawApiService", () => { }); describe("API Request Methods", () => { - function errorRequest( - method: "httpGet" | "httpDelete" | "httpPost" | "httpPatch", - error: ApiErrorDetails - ) { - return spyOn(service as any, method).and.callFake(() => { - const subject = new Subject>(); - subject.error(error); - return subject; - }); - } - - function successRequest( - method: "httpGet" | "httpDelete" | "httpPost" | "httpPatch", - response: ApiResponse - ): jasmine.Spy { - return spyOn(service as any, method).and.callFake(() => { - const subject = new BehaviorSubject>(response); - setTimeout(() => subject.complete(), 0); - return subject; - }); - } - - describe("apiList", () => { - it("should call httpGet", () => { - const response = { - meta: multiMeta, - data: [], - } as ApiResponse; - const spy = successRequest("httpGet", response); - - service["apiList"]("/broken_link").subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link"); - }); - - it("should handle empty response", () => { - const response = { - meta: multiMeta, - data: [], - } as ApiResponse; - successRequest("httpGet", response); - - service["apiList"]("/broken_link").subscribe((data) => { - expect(data).toEqual([]); - }, shouldNotFail); - }); - - it("should handle response", () => { - const response = { - meta: multiMeta, - data: multiResponse, - } as ApiResponse; - successRequest("httpGet", response); - - service["apiList"]("/broken_link").subscribe((data) => { - expect(data).toEqual( - multiResponse.map( - (model) => new MockModel(model, service["injector"]) - ) - ); - }, shouldNotFail); - }); - - it("should handle error response", () => { - errorRequest("httpGet", apiErrorDetails); - - service["apiList"]("/broken_link").subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorDetails); - } - ); - }); - - it("should handle error info response", () => { - errorRequest("httpGet", apiErrorInfoDetails); - - service["apiList"]("/broken_link").subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorInfoDetails); - } - ); - }); - - it("should complete on success", (done) => { - const response = { - meta: multiMeta, - data: multiResponse, - } as ApiResponse; - successRequest("httpGet", response); - - service["apiList"]("/broken_link").subscribe( - () => {}, - shouldNotFail, - () => { - expect(true).toBeTruthy(); - done(); - } - ); - }); - }); - - describe("apiFilter", () => { - it("should call httpPost", () => { - const response = { - meta: multiMeta, - data: multiResponse, - } as ApiResponse; - const spy = successRequest("httpPost", response); - - service["apiFilter"]("/broken_link", {}).subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link", {}); - }); - - it("should call httpPost with body", () => { - const response = { - meta: multiMeta, - data: multiResponse, - } as ApiResponse; - const spy = successRequest("httpPost", response); - - service["apiFilter"]("/broken_link", { - paging: { items: 3 }, - }).subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link", { - paging: { items: 3 }, - }); - }); - - it("should handle response", () => { - const response = { - meta: multiMeta, - data: multiResponse, - } as ApiResponse; - successRequest("httpPost", response); - - service["apiFilter"]("/broken_link", {}).subscribe((data) => { - expect(data).toEqual([ - new MockModel(singleResponse, service["injector"]), - ]); - }, shouldNotFail); - }); - - it("should handle error response", () => { - errorRequest("httpPost", apiErrorDetails); - - service["apiFilter"]("/broken_link", {}).subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorDetails); - } - ); - }); - - it("should handle error info response", () => { - errorRequest("httpPost", apiErrorInfoDetails); - - service["apiFilter"]("/broken_link", {}).subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorInfoDetails); - } - ); - }); - - it("should complete on success", (done) => { - const response = { - meta: multiMeta, - data: multiResponse, - } as ApiResponse; - successRequest("httpPost", response); - - service["apiFilter"]("/broken_link", {}).subscribe( - () => {}, - shouldNotFail, - () => { - expect(true).toBeTruthy(); - done(); - } - ); - }); - }); - - describe("apiShow", () => { - it("should call httpGet", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - const spy = successRequest("httpGet", response); - - service["apiShow"]("/broken_link").subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link"); - }); - - it("should handle response", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpGet", response); - - service["apiShow"]("/broken_link").subscribe((data) => { - expect(data).toEqual( - new MockModel(singleResponse, service["injector"]) - ); - }, shouldNotFail); - }); - - it("should handle error response", () => { - errorRequest("httpGet", apiErrorDetails); - - service["apiShow"]("/broken_link").subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorDetails); - } - ); - }); - - it("should handle error info response", () => { - errorRequest("httpGet", apiErrorInfoDetails); - - service["apiShow"]("/broken_link").subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorInfoDetails); - } - ); - }); - - it("should complete on success", (done) => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpGet", response); - - service["apiShow"]("/broken_link").subscribe( - () => {}, - shouldNotFail, - () => { - expect(true).toBeTruthy(); - done(); - } - ); - }); - }); - - describe("apiCreate", () => { - it("should call httpPost", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - const spy = successRequest("httpPost", response); - - service["apiCreate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link", { - id: undefined, - name: undefined, - caseConversion: undefined, - }); - }); - - it("should call httpPost with body", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - const spy = successRequest("httpPost", response); - - service["apiCreate"]( - "/broken_link", - new MockModel( - { - name: "Custom Name", - }, - service["injector"] - ) - ).subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link", { - id: undefined, - name: "Custom Name", - caseConversion: undefined, - }); - }); - - it("should handle response", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpPost", response); - - service["apiCreate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe((data) => { - expect(data).toEqual( - new MockModel(singleResponse, service["injector"]) - ); - }, shouldNotFail); - }); - - it("should handle error response", () => { - errorRequest("httpPost", apiErrorDetails); - - service["apiCreate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe(shouldNotSucceed, (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorDetails); + type HttpFunctionCall = "httpGet" | "httpDelete" | "httpPost" | "httpPatch"; + + const tests: { + method: string; + http: HttpFunctionCall; + hasBody: boolean; + singleResult: boolean; + multiResult: boolean; + }[] = [ + { + method: "apiList", + http: "httpGet", + hasBody: false, + singleResult: false, + multiResult: true, + }, + { + method: "apiFilter", + http: "httpPost", + hasBody: true, + singleResult: false, + multiResult: true, + }, + { + method: "apiShow", + http: "httpGet", + hasBody: false, + singleResult: true, + multiResult: false, + }, + { + method: "apiCreate", + http: "httpPost", + hasBody: true, + singleResult: true, + multiResult: false, + }, + { + method: "apiUpdate", + http: "httpPatch", + hasBody: true, + singleResult: true, + multiResult: false, + }, + { + method: "apiDestroy", + http: "httpDelete", + hasBody: false, + singleResult: true, + multiResult: false, + }, + ]; + tests.forEach(({ method, http, hasBody, singleResult, multiResult }) => { + function errorRequest(error: ApiErrorDetails): jasmine.Spy { + const spy = jasmine.createSpy(http).and.callFake(() => { + const subject = new Subject(); + subject.error(error); + return subject; }); - }); - - it("should handle error info response", () => { - errorRequest("httpPost", apiErrorInfoDetails); - - service["apiCreate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe(shouldNotSucceed, (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorInfoDetails); + service[http] = spy; + return spy; + } + + function successRequest(response: ApiResponse): jasmine.Spy { + const spy = jasmine.createSpy(http).and.callFake(() => { + const subject = new BehaviorSubject>(response); + setTimeout(() => subject.complete(), 0); + return subject; }); - }); - - it("should complete on success", (done) => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpPost", response); - - service["apiCreate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe( - () => {}, - shouldNotFail, - () => { - expect(true).toBeTruthy(); - done(); + service[http] = spy; + return spy; + } + + function functionCall() { + if (hasBody) { + return service[method]("/broken_link", { example: "body" }); + } else { + return service[method]("/broken_link"); + } + } + + describe(method, () => { + it(`should call ${http}`, () => { + const response = singleResult + ? { meta: meta.single, data: responses.single } + : { meta: meta.multi, data: [] }; + const spy = successRequest(response); + functionCall().subscribe(); + + if (hasBody) { + expect(spy).toHaveBeenCalledWith("/broken_link", { + example: "body", + }); + } else { + expect(spy).toHaveBeenCalledWith("/broken_link"); } - ); - }); - }); - - describe("apiUpdate", () => { - it("should call httpPatch", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - const spy = successRequest("httpPatch", response); - - service["apiUpdate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link", { - id: undefined, - name: undefined, - caseConversion: undefined, - }); - }); - - it("should call httpPost with body", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - const spy = successRequest("httpPatch", response); - - service["apiUpdate"]( - "/broken_link", - new MockModel( - { - name: "Custom Name", - }, - service["injector"] - ) - ).subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link", { - id: undefined, - name: "Custom Name", - caseConversion: undefined, }); - }); - - it("should handle response", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpPatch", response); - - service["apiUpdate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe((data) => { - expect(data).toEqual( - new MockModel(singleResponse, service["injector"]) - ); - }, shouldNotFail); - }); - it("should handle error response", () => { - errorRequest("httpPatch", apiErrorDetails); + if (singleResult) { + it("should handle response", (done) => { + const response = { meta: meta.single, data: responses.single }; + successRequest(response); + functionCall().subscribe((data) => { + // Destroy returns void + if (method === "apiDestroy") { + expect(data).toBe(null); + } else { + expect(data).toEqual( + new MockModel(response.data, service["injector"]) + ); + } + done(); + }, shouldNotFail); + }); + } - service["apiUpdate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe(shouldNotSucceed, (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorDetails); - }); - }); + if (multiResult) { + it("should handle empty response", (done) => { + successRequest({ meta: meta.multi, data: [] }); + functionCall().subscribe((data) => { + expect(data).toEqual([]); + done(); + }, shouldNotFail); + }); - it("should handle error info response", () => { - errorRequest("httpPatch", apiErrorInfoDetails); + it("should handle response", (done) => { + const response = { meta: meta.multi, data: responses.multi }; + successRequest(response); + functionCall().subscribe((data) => { + expect(data).toEqual( + response.data.map( + (model) => new MockModel(model, service["injector"]) + ) + ); + done(); + }, shouldNotFail); + }); + } - service["apiUpdate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe(shouldNotSucceed, (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorInfoDetails); + it("should handle error response", (done) => { + errorRequest(responses.error); + functionCall().subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual(responses.error); + done(); + }); }); - }); - it("should complete on success", (done) => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpPatch", response); - - service["apiUpdate"]( - "/broken_link", - new MockModel({}, service["injector"]) - ).subscribe( - () => {}, - shouldNotFail, - () => { - expect(true).toBeTruthy(); + it("should handle error info response", (done) => { + errorRequest(responses.errorInfo); + functionCall().subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual(responses.errorInfo); done(); - } - ); - }); - }); - - describe("apiDestroy", () => { - it("should call httpDelete", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - const spy = successRequest("httpDelete", response); - - service["apiDestroy"]("/broken_link").subscribe(); - expect(spy).toHaveBeenCalledWith("/broken_link"); - }); - - it("should handle response", () => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpDelete", response); - - service["apiDestroy"]("/broken_link").subscribe((data) => { - expect(data).toBe(null); - }, shouldNotFail); - }); - - it("should handle error response", () => { - errorRequest("httpDelete", apiErrorDetails); - - service["apiDestroy"]("/broken_link").subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorDetails); - } - ); - }); - - it("should handle error info response", () => { - errorRequest("httpDelete", apiErrorInfoDetails); + }); + }); - service["apiDestroy"]("/broken_link").subscribe( - shouldNotSucceed, - (err: ApiErrorDetails) => { - expect(err).toEqual(apiErrorInfoDetails); + it("should complete on success", (done) => { + if (singleResult) { + successRequest({ meta: meta.single, data: responses.single }); + } else { + successRequest({ meta: meta.multi, data: responses.multi }); } - ); - }); - it("should complete on success", (done) => { - const response = { - meta: singleMeta, - data: singleResponse, - } as ApiResponse; - successRequest("httpDelete", response); - - service["apiDestroy"]("/broken_link").subscribe( - () => {}, - shouldNotFail, - () => { + functionCall().subscribe(noop, shouldNotFail, () => { expect(true).toBeTruthy(); done(); - } - ); + }); + }); }); }); }); diff --git a/src/app/services/baw-api/baw-api.service.ts b/src/app/services/baw-api/baw-api.service.ts index 37951e379..edb987b58 100644 --- a/src/app/services/baw-api/baw-api.service.ts +++ b/src/app/services/baw-api/baw-api.service.ts @@ -11,31 +11,19 @@ import { KeysOfType, XOR } from "@helpers/advancedTypes"; import { API_ROOT } from "@helpers/app-initializer/app-initializer"; import { AbstractModel } from "@models/AbstractModel"; import { SessionUser } from "@models/User"; -import { Observable } from "rxjs"; +import { Observable, throwError } from "rxjs"; import { map } from "rxjs/operators"; +import { ApiErrorDetails } from "./api.interceptor.service"; export const defaultApiPageSize = 25; - -export const apiReturnCodes = { - unknown: -1, - success: 200, - created: 201, - badRequest: 400, - unauthorized: 401, - forbidden: 403, - notFound: 404, - unsupportedMediaType: 415, - unprocessableEntity: 422, - internalServerFailure: 500, -}; - +export const unknownErrorCode = -1; export const STUB_MODEL_BUILDER = new InjectionToken("test.model.builder"); /** * Interface with BAW Server Rest API */ @Injectable() -export abstract class BawApiService { +export class BawApiService { private platform: any; /* @@ -163,6 +151,21 @@ export abstract class BawApiService { localStorage.removeItem(this.userLocalStorageKey); } + /** + * Handle custom Errors thrown in API services + * + * @param err Error + */ + protected handleError(err: ApiErrorDetails | Error): Observable { + if (err instanceof Error) { + return throwError({ + status: unknownErrorCode, + message: err.message, + } as ApiErrorDetails); + } + return throwError(err); + } + /** * Get response from list route * @@ -527,7 +530,7 @@ export interface Meta extends Filters { */ export interface ApiResponse { /** Response metadata */ - meta: Meta; + meta: Meta; /** Response data */ data: T[] | T; } diff --git a/src/app/services/baw-api/baw-form-api.service.spec.ts b/src/app/services/baw-api/baw-form-api.service.spec.ts new file mode 100644 index 000000000..361407cd9 --- /dev/null +++ b/src/app/services/baw-api/baw-form-api.service.spec.ts @@ -0,0 +1,398 @@ +import { HTTP_INTERCEPTORS } from "@angular/common/http"; +import { TestRequest } from "@angular/common/http/testing"; +import { API_ROOT } from "@helpers/app-initializer/app-initializer"; +import { + createHttpFactory, + HttpMethod, + SpectatorHttp, +} from "@ngneat/spectator"; +import { MockAppConfigModule } from "@services/config/configMock.module"; +import { generateApiErrorResponse } from "@test/fakes/ApiErrorDetails"; +import { modelData } from "@test/helpers/faker"; +import { getCallArgs, nStepObservable } from "@test/helpers/general"; +import { noop, Subject } from "rxjs"; +import { ApiErrorDetails, BawApiInterceptor } from "./api.interceptor.service"; +import { STUB_MODEL_BUILDER, unknownErrorCode } from "./baw-api.service"; +import { shouldNotFail, shouldNotSucceed } from "./baw-api.service.spec"; +import { BawFormApiService } from "./baw-form-api.service"; +import { MockShowApiService } from "./mock/apiMocks.service"; +import { MockForm } from "./mock/bawFormApiMock.service"; +import { MockSecurityService } from "./mock/securityMock.service"; +import { SecurityService } from "./security/security.service"; +import { UserService } from "./user/user.service"; + +describe("bawFormApiService", () => { + let apiRoot: string; + let spec: SpectatorHttp>; + const createService = createHttpFactory>({ + service: BawFormApiService, + imports: [MockAppConfigModule], + providers: [ + { provide: SecurityService, useClass: MockSecurityService }, + { provide: UserService, useClass: MockShowApiService }, + { + provide: HTTP_INTERCEPTORS, + useClass: BawApiInterceptor, + multi: true, + }, + { provide: STUB_MODEL_BUILDER, useValue: MockForm }, + ], + }); + + function intercept(spy: any, response: any, error: ApiErrorDetails) { + const subject = new Subject(); + spy.and.callFake(() => subject); + return nStepObservable(subject, () => response ?? error, !!error); + } + + beforeEach(() => { + localStorage.clear(); + spec = createService(); + apiRoot = spec.inject(API_ROOT); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe("makeFormRequest", () => { + let defaultBody: URLSearchParams; + let successHtmlRequestPage: string; + let apiHtmlRequestSpy: jasmine.Spy; + let apiFormRequestSpy: jasmine.Spy; + + function makeFormRequest( + formEndpoint: string, + submissionEndpoint: string, + body: (authToken: string) => URLSearchParams + ) { + return spec.service["makeFormRequest"]( + formEndpoint, + submissionEndpoint, + body + ); + } + + function interceptHtmlRequest(response: string, error?: ApiErrorDetails) { + apiHtmlRequestSpy = jasmine.createSpy("apiHtmlRequest"); + spec.service["apiHtmlRequest"] = apiHtmlRequestSpy; + return intercept(apiHtmlRequestSpy, response, error); + } + + function interceptFormRequest(response: string, error?: ApiErrorDetails) { + apiFormRequestSpy = jasmine.createSpy("apiFormRequest"); + spec.service["apiFormRequest"] = apiFormRequestSpy; + return intercept(apiFormRequestSpy, response, error); + } + + beforeEach(() => { + defaultBody = new URLSearchParams(); + successHtmlRequestPage = ``; + }); + + it("should call apiHtmlRequest", () => { + interceptHtmlRequest(""); + makeFormRequest( + "/broken_html_link", + "/broken_link", + () => defaultBody + ).subscribe(noop, noop); + expect(apiHtmlRequestSpy).toHaveBeenCalledWith("/broken_html_link"); + }); + + it("should throw error if token is not found", (done) => { + interceptHtmlRequest(""); + makeFormRequest( + "/broken_link", + "/broken_link", + () => defaultBody + ).subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual({ + status: unknownErrorCode, + message: "Unable to retrieve authenticity token for form request.", + } as ApiErrorDetails); + done(); + }); + }); + + it("should call apiFormRequest with submission endpoint", async () => { + const promise = interceptHtmlRequest(successHtmlRequestPage); + interceptFormRequest(""); + makeFormRequest( + "/broken_link", + "/broken_form_link", + () => defaultBody + ).subscribe(noop, noop); + await promise; + expect(getCallArgs(apiFormRequestSpy)[0]).toBe("/broken_form_link"); + }); + + it("should call apiFormRequest with body", async () => { + const body = (_authToken: string): URLSearchParams => { + defaultBody.set("user[example]", "example value"); + defaultBody.set("authToken", _authToken); + return defaultBody; + }; + const authToken = modelData.authToken(); + const promise = interceptHtmlRequest( + `` + ); + interceptFormRequest(""); + makeFormRequest("/broken_link", "/broken_form_link", (_authToken) => + body(_authToken) + ).subscribe(noop, noop); + await promise; + expect(getCallArgs(apiFormRequestSpy)[1].toString()).toBe( + "user%5Bexample%5D=example+value&authToken=" + authToken + ); + }); + + it("should throw error if recaptcha error message in response", (done) => { + interceptHtmlRequest(successHtmlRequestPage); + interceptFormRequest(` +

+ Captcha response was not correct. Please try again. +

+ `); + makeFormRequest( + "/broken_link", + "/broken_form_link", + () => defaultBody + ).subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual({ + status: unknownErrorCode, + message: "Captcha response was not correct.", + } as ApiErrorDetails); + done(); + }); + }); + + it("should return page of successful response", (done) => { + interceptHtmlRequest(successHtmlRequestPage); + interceptFormRequest(""); + makeFormRequest( + "/broken_link", + "/broken_form_link", + () => defaultBody + ).subscribe((page: string) => { + expect(page).toBe(""); + done(); + }, shouldNotFail); + }); + + it("should complete on success", (done) => { + interceptHtmlRequest(successHtmlRequestPage); + interceptFormRequest(""); + makeFormRequest( + "/broken_link", + "/broken_form_link", + () => defaultBody + ).subscribe({ + complete: () => { + expect(true).toBeTrue(); + done(); + }, + }); + }); + }); + + describe("getRecaptchaSeed", () => { + let apiHtmlRequestSpy: jasmine.Spy; + + function interceptHtmlRequest(page: string, error?: ApiErrorDetails) { + apiHtmlRequestSpy = jasmine.createSpy("apiHtmlRequest"); + spec.service["apiHtmlRequest"] = apiHtmlRequestSpy; + return intercept(apiHtmlRequestSpy, page, error); + } + + function getRecaptchaSeed(page: string) { + return spec.service["getRecaptchaSeed"](page); + } + + it("should call apiHtmlRequest", () => { + interceptHtmlRequest(""); + getRecaptchaSeed("/broken_link").subscribe(noop, noop); + expect(apiHtmlRequestSpy).toHaveBeenCalledWith("/broken_link"); + }); + + it("should extract seed from page", (done) => { + const seed = modelData.authToken(); + const action = "test_action"; + interceptHtmlRequest( + `grecaptcha.execute('${seed}', {action: '${action}'})` + ); + getRecaptchaSeed("/broken_link").subscribe((settings) => { + expect(settings.seed).toBe(seed); + done(); + }, shouldNotFail); + }); + + it("should extract action from page", (done) => { + const seed = modelData.authToken(); + const action = "test_action"; + interceptHtmlRequest( + `grecaptcha.execute('${seed}', {action: '${action}'})` + ); + getRecaptchaSeed("/broken_link").subscribe((settings) => { + expect(settings.action).toBe(action); + done(); + }, shouldNotFail); + }); + + it("should throw error if failed to extract seed from page", (done) => { + interceptHtmlRequest(""); + getRecaptchaSeed("/broken_link").subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual({ + status: unknownErrorCode, + message: "Unable to setup recaptcha.", + } as ApiErrorDetails); + done(); + }); + }); + + it("should complete on success", (done) => { + const seed = modelData.authToken(); + const action = "test_action"; + interceptHtmlRequest( + `grecaptcha.execute('${seed}', {action: '${action}'})` + ); + getRecaptchaSeed("/broken_link").subscribe(noop, noop, () => { + expect(true).toBeTrue(); + done(); + }); + }); + }); + + describe("apiHtmlRequest", () => { + function interceptRequest(path: string) { + return spec.expectOne(apiRoot + path, HttpMethod.GET); + } + + function apiHtmlRequest(path: string) { + return spec.service["apiHtmlRequest"](path); + } + + it("should create get request", () => { + apiHtmlRequest("/broken_link").subscribe(noop, noop); + expect(interceptRequest("/broken_link")).toBeInstanceOf(TestRequest); + }); + + it("should set responseType to text", () => { + apiHtmlRequest("/broken_link").subscribe(noop, noop); + const responseType = interceptRequest("/broken_link").request + .responseType; + expect(responseType).toBe("text"); + }); + + it("should set accept header to text/html", () => { + apiHtmlRequest("/broken_link").subscribe(noop, noop); + const headers = interceptRequest("/broken_link").request.headers; + expect(headers.get("Accept")).toBe("text/html"); + }); + + it("should not set content type headers", () => { + apiHtmlRequest("/broken_link").subscribe(noop, noop); + const headers = interceptRequest("/broken_link").request.headers; + expect(headers.get("Content-Type")).not.toBeTruthy(); + }); + + it("should return page contents", (done) => { + const response = ""; + apiHtmlRequest("/broken_link").subscribe((page) => { + expect(page).toBe(response); + done(); + }); + interceptRequest("/broken_link").flush(response); + }); + + it("should handle api error", (done) => { + apiHtmlRequest("/broken_link").subscribe(shouldNotSucceed, (err) => { + expect(err?.status).toBe(500); + done(); + }); + interceptRequest("/broken_link").flush( + generateApiErrorResponse("Internal Server Error"), + { status: 500, statusText: "Internal Server Error" } + ); + }); + + it("should complete on success", (done) => { + apiHtmlRequest("/broken_link").subscribe(noop, noop, () => { + expect(true).toBeTruthy(); + done(); + }); + interceptRequest("/broken_link").flush(""); + }); + }); + + describe("apiFormRequest", () => { + function interceptRequest(path: string) { + return spec.expectOne(apiRoot + path, HttpMethod.POST); + } + + function apiFormRequest( + path: string, + formData: URLSearchParams = new URLSearchParams() + ) { + return spec.service["apiFormRequest"](path, formData); + } + + it("should create post request", () => { + apiFormRequest("/broken_link").subscribe(noop, noop); + expect(interceptRequest("/broken_link")).toBeInstanceOf(TestRequest); + }); + + it("should set responseType to text", () => { + apiFormRequest("/broken_link").subscribe(noop, noop); + const responseType = interceptRequest("/broken_link").request + .responseType; + expect(responseType).toBe("text"); + }); + + it("should set accept header to text/html", () => { + apiFormRequest("/broken_link").subscribe(noop, noop); + const headers = interceptRequest("/broken_link").request.headers; + expect(headers.get("Accept")).toBe("text/html"); + }); + + it("should set content type header to form-urlencoded", () => { + apiFormRequest("/broken_link").subscribe(noop, noop); + const headers = interceptRequest("/broken_link").request.headers; + expect(headers.get("Content-Type")).toBe( + "application/x-www-form-urlencoded" + ); + }); + + it("should insert form data", () => { + const formData = new URLSearchParams({ + "user[login]": "example username", + "user[password]": "Ex@mp1e_P@55w0rd+=", + }); + apiFormRequest("/broken_link", formData).subscribe(noop, noop); + const body = interceptRequest("/broken_link").request.body; + expect(body).toBe( + "user%5Blogin%5D=example+username&" + + "user%5Bpassword%5D=Ex%40mp1e_P%4055w0rd%2B%3D" + ); + }); + + it("should handle api error", (done) => { + apiFormRequest("/broken_link").subscribe(shouldNotSucceed, (err) => { + expect(err?.status).toBe(500); + done(); + }); + interceptRequest("/broken_link").flush( + generateApiErrorResponse("Internal Server Error"), + { status: 500, statusText: "Internal Server Error" } + ); + }); + + it("should complete on success", (done) => { + apiFormRequest("/broken_link").subscribe(noop, noop, () => { + expect(true).toBeTruthy(); + done(); + }); + interceptRequest("/broken_link").flush(""); + }); + }); +}); diff --git a/src/app/services/baw-api/baw-form-api.service.ts b/src/app/services/baw-api/baw-form-api.service.ts new file mode 100644 index 000000000..69e2bd52c --- /dev/null +++ b/src/app/services/baw-api/baw-form-api.service.ts @@ -0,0 +1,155 @@ +import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { Inject, Injectable, Injector } from "@angular/core"; +import { API_ROOT } from "@helpers/app-initializer/app-initializer"; +import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; +import { AbstractModel } from "@models/AbstractModel"; +import { Observable } from "rxjs"; +import { catchError, first, map, mergeMap, tap } from "rxjs/operators"; +import { BawApiService, STUB_MODEL_BUILDER } from "./baw-api.service"; + +/* + * Reads through a HTML document for recaptcha setup code to extract the + * seed and action. + */ +const extractRecaptchaValues = /grecaptcha\.execute\('(.+?)', {action: '(.+?)'}\)/; + +/* + * Looks for a hidden input in HTML document, name of input is + * "authenticity_token". The auth token, which is required for form + * based requests, is set inside the value property. + */ +const authTokenRegex = /name="authenticity_token" value="(.+?)"/; + +/** + * Interface with BAW Server using Form/HTML based requests similar to a browser. + * This is useful when an official API route has not been created, and should be + * treated as a temporary measure while the baw-server lags behind development. + */ +@Injectable() +export class BawFormApiService< + Model extends AbstractModel +> extends BawApiService { + public constructor( + http: HttpClient, + @Inject(API_ROOT) apiRoot: string, + @Inject(STUB_MODEL_BUILDER) + classBuilder: new (_: Record, _injector?: Injector) => Model, + injector: Injector + ) { + super(http, apiRoot, classBuilder, injector); + } + + /** + * Make a form request on non-JSON api endpoints with recaptcha + * token + * + * @param formEndpoint Endpoint to retrieve form HTML + * @param submissionEndpoint Endpoint to send form data to + * @param body Form data to insert into api request + * @returns HTML page for request. Response may be a success, however the + * html contains error messages which need to be extracted + */ + protected makeFormRequest( + formEndpoint: string, + submissionEndpoint: string, + body: (authToken: string) => URLSearchParams + ): Observable { + // Request HTML document to retrieve form containing auth token + return this.apiHtmlRequest(formEndpoint).pipe( + map((page: string) => { + // Extract auth token if exists + const token = authTokenRegex.exec(page)?.[1]; + if (!isInstantiated(token)) { + throw new Error( + "Unable to retrieve authenticity token for form request." + ); + } + return token; + }), + // Mimic a traditional form-based request + mergeMap((token: string) => + this.apiFormRequest(submissionEndpoint, body(token)) + ), + tap((response: string) => { + // Check for recaptcha error message in page body + const errorMsg = "Captcha response was not correct."; + if (response.includes(errorMsg)) { + throw Error(errorMsg); + } + }), + // Complete observable + first(), + // Handle custom errors + catchError(this.handleError) + ); + } + + /** + * Retrieve a recatpcha seed for a form + * + * @param path Path to retrieve recatpcha seed from + * @param extractSeed Regex to extract recaptcha seed from HTML response + */ + protected getRecaptchaSeed(path: string): Observable { + // Mock a HTML request to the server + return this.apiHtmlRequest(path).pipe( + map((page: string) => { + // Extract seed and action from page + const values = extractRecaptchaValues.exec(page); + const seed = values?.[1]; + const action = values?.[2]; + + if (!seed || !action) { + throw new Error("Unable to setup recaptcha."); + } + return { seed, action }; + }), + // Complete observable + first(), + // Handle custom errors + catchError(this.handleError) + ); + } + + /** + * Request a HTML page from the API. This will be used to extract important + * information required to make form-based requests later on + * + * @param path API path + */ + protected apiHtmlRequest(path: string): Observable { + return this.http.get(this.getPath(path), { + responseType: "text", + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: new HttpHeaders({ Accept: "text/html" }), + }); + } + + /** + * Create a form-based request to interface with non JSON API endpoints of the + * baw-server. As a side affect, this will also generate a session cookies + * which can be used to create an auth token. + * + * @param path API path + * @param formData Request body + */ + protected apiFormRequest( + path: string, + formData: URLSearchParams + ): Observable { + return this.http.post(this.getPath(path), formData.toString(), { + responseType: "text", + headers: new HttpHeaders({ + // eslint-disable-next-line @typescript-eslint/naming-convention + Accept: "text/html", + // eslint-disable-next-line @typescript-eslint/naming-convention + "Content-Type": "application/x-www-form-urlencoded", + }), + }); + } +} + +export interface RecaptchaSettings { + seed: string; + action: string; +} diff --git a/src/app/services/baw-api/cms/cms.service.spec.ts b/src/app/services/baw-api/cms/cms.service.spec.ts index a483b8c0a..29a6faf14 100644 --- a/src/app/services/baw-api/cms/cms.service.spec.ts +++ b/src/app/services/baw-api/cms/cms.service.spec.ts @@ -13,7 +13,6 @@ import { CMS, CmsService } from "./cms.service"; export const cmsRoot = testApiConfig.environment.apiRoot + "/cms/"; describe("CmsService", () => { - let api: SecurityService; let spectator: SpectatorHttp; const createService = createHttpFactory({ service: CmsService, @@ -24,7 +23,6 @@ describe("CmsService", () => { beforeEach(() => { spectator = createService(); - api = spectator.inject(SecurityService); }); afterEach(() => spectator.controller.verify()); diff --git a/src/app/services/baw-api/mock/apiMocks.service.ts b/src/app/services/baw-api/mock/apiMocks.service.ts index 0a4720c5f..e0e51dc64 100644 --- a/src/app/services/baw-api/mock/apiMocks.service.ts +++ b/src/app/services/baw-api/mock/apiMocks.service.ts @@ -21,10 +21,13 @@ export const MOCK = new ServiceToken( "STANDARD_API_SERVICE" ); +// eslint-disable-next-line @typescript-eslint/no-unused-vars const multipleModels = (...args: any[]) => new Observable(); +// eslint-disable-next-line @typescript-eslint/no-unused-vars const singleModel = (...args: any[]) => new Observable(); +// eslint-disable-next-line @typescript-eslint/no-unused-vars const deleteMock = (...args: any[]) => new Observable(); @Injectable() diff --git a/src/app/services/baw-api/mock/bawFormApiMock.service.ts b/src/app/services/baw-api/mock/bawFormApiMock.service.ts new file mode 100644 index 000000000..0b59210e5 --- /dev/null +++ b/src/app/services/baw-api/mock/bawFormApiMock.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@angular/core"; +import { AbstractForm } from "@models/AbstractForm"; + +export class MockForm extends AbstractForm { + public getBody(): URLSearchParams { + return new URLSearchParams(); + } +} + +@Injectable() +export class MockBawFormApiService { + public constructor() {} + + public isLoggedIn() { + return false; + } + + public getSessionUser() { + return null; + } +} diff --git a/src/app/services/baw-api/mock/shallowSitesMock.service.ts b/src/app/services/baw-api/mock/shallowSitesMock.service.ts index abec1a640..51db9dc95 100644 --- a/src/app/services/baw-api/mock/shallowSitesMock.service.ts +++ b/src/app/services/baw-api/mock/shallowSitesMock.service.ts @@ -5,6 +5,7 @@ import { MockModel } from "./baseApiMock.service"; @Injectable() export class MockShallowSitesService extends MockStandardApiService { + // eslint-disable-next-line @typescript-eslint/no-unused-vars public orphans(...args: any[]) { return new Observable(); } diff --git a/src/app/services/baw-api/report/contact-us.service.spec.ts b/src/app/services/baw-api/report/contact-us.service.spec.ts new file mode 100644 index 000000000..8f2dcaf1e --- /dev/null +++ b/src/app/services/baw-api/report/contact-us.service.spec.ts @@ -0,0 +1 @@ +// TODO Implement unit tests diff --git a/src/app/services/baw-api/report/contact-us.service.ts b/src/app/services/baw-api/report/contact-us.service.ts new file mode 100644 index 000000000..8f5a1fdb4 --- /dev/null +++ b/src/app/services/baw-api/report/contact-us.service.ts @@ -0,0 +1,70 @@ +import { HttpClient } from "@angular/common/http"; +import { Inject, Injectable, Injector } from "@angular/core"; +import { BawFormApiService } from "@baw-api/baw-form-api.service"; +import { API_ROOT } from "@helpers/app-initializer/app-initializer"; +import { stringTemplate } from "@helpers/stringTemplate/stringTemplate"; +import { Description, Param } from "@interfaces/apiInterfaces"; +import { AbstractForm } from "@models/AbstractForm"; +import { bawPersistAttr } from "@models/AttributeDecorators"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; + +const contactUsEndpoint = stringTemplate`/contact_us`; + +@Injectable() +export class ContactUsService extends BawFormApiService { + public constructor( + http: HttpClient, + @Inject(API_ROOT) apiRoot: string, + injector: Injector + ) { + super(http, apiRoot, ContactUs, injector); + } + + public contactUs(details: ContactUs): Observable { + return this.makeFormRequest( + contactUsEndpoint(), + contactUsEndpoint(), + (token) => details.getBody(token) + ).pipe( + // Void output + map(() => undefined) + ); + } + + public seed() { + return this.getRecaptchaSeed(contactUsEndpoint()); + } +} + +export interface IContactUs { + name: Param; + email: Param; + content: Description; + recaptchaToken: string; +} + +export class ContactUs extends AbstractForm implements IContactUs { + public readonly kind = "ContactUs"; + @bawPersistAttr + public readonly name: Param = ""; + @bawPersistAttr + public readonly email: Param = ""; + @bawPersistAttr + public readonly content: Description; + @bawPersistAttr + public readonly recaptchaToken: string; + + public getBody(token: string): URLSearchParams { + this.validateRecaptchaToken(); + const body = new URLSearchParams(); + body.set("data_class_contact_us[name]", this.name); + body.set("data_class_contact_us[email]", this.email); + body.set("data_class_contact_us[content]", this.content); + body.set("g-recaptcha-response-data[contact_us]", this.recaptchaToken); + body.set("g-recaptcha-response", ""); + body.set("commit", "Submit"); + body.set("authenticity_token", token); + return body; + } +} diff --git a/src/app/services/baw-api/report/report-problem.service.spec.ts b/src/app/services/baw-api/report/report-problem.service.spec.ts new file mode 100644 index 000000000..899e9e2cc --- /dev/null +++ b/src/app/services/baw-api/report/report-problem.service.spec.ts @@ -0,0 +1 @@ +//TODO Implement tests diff --git a/src/app/services/baw-api/report/report-problem.service.ts b/src/app/services/baw-api/report/report-problem.service.ts new file mode 100644 index 000000000..8f6159002 --- /dev/null +++ b/src/app/services/baw-api/report/report-problem.service.ts @@ -0,0 +1,91 @@ +import { HttpClient } from "@angular/common/http"; +import { Inject, Injectable, Injector } from "@angular/core"; +import { BawFormApiService } from "@baw-api/baw-form-api.service"; +import { API_ROOT } from "@helpers/app-initializer/app-initializer"; +import { stringTemplate } from "@helpers/stringTemplate/stringTemplate"; +import { Description, Param } from "@interfaces/apiInterfaces"; +import { AbstractForm } from "@models/AbstractForm"; +import { bawDateTime, bawPersistAttr } from "@models/AttributeDecorators"; +import { DateTime } from "luxon"; +import { Observable } from "rxjs"; +import { catchError, first, map } from "rxjs/operators"; + +const reportProblemEndpoint = stringTemplate`/bug_report`; + +@Injectable() +export class ReportProblemService extends BawFormApiService { + public constructor( + http: HttpClient, + @Inject(API_ROOT) apiRoot: string, + injector: Injector + ) { + super(http, apiRoot, ReportProblem, injector); + } + + public reportProblem(details: ReportProblem): Observable { + const validateEmail = (page: string): void => { + const errMsg = + 'id="data_class_bug_report_email" />is invalid'; + if (page.includes(errMsg)) { + throw Error("Email address is invalid"); + } + }; + + return this.makeFormRequest( + reportProblemEndpoint(), + reportProblemEndpoint(), + (token) => details.getBody(token) + ).pipe( + map((page) => validateEmail(page)), + // Complete observable + first(), + catchError(this.handleError) + ); + } + + public seed() { + return this.getRecaptchaSeed(reportProblemEndpoint()); + } +} + +export interface IReportProblem { + name: Param; + email: Param; + date: Date | DateTime; + description: Description; + content: Description; + recaptchaToken: string; +} + +export class ReportProblem + extends AbstractForm + implements IReportProblem { + public readonly kind = "ReportProblem"; + @bawPersistAttr + public readonly name: Param; + @bawPersistAttr + public readonly email: Param; + @bawDateTime({ persist: true }) + public readonly date: DateTime; + @bawPersistAttr + public readonly description: Description; + @bawPersistAttr + public readonly content: Description; + @bawPersistAttr + public readonly recaptchaToken: string; + + public getBody(token: string): URLSearchParams { + this.validateRecaptchaToken(); + const body = new URLSearchParams(); + body.set("data_class_bug_report[name]", this.name ?? ""); + body.set("data_class_bug_report[email]", this.email ?? ""); + body.set("data_class_bug_report[date]", this.date.toFormat("yyyy/MM/dd")); + body.set("data_class_bug_report[content]", this.content); + body.set("data_class_bug_report[description]", this.description); + body.set("g-recaptcha-response-data[bug_report]", this.recaptchaToken); + body.set("g-recaptcha-response", ""); + body.set("commit", "Submit"); + body.set("authenticity_token", token); + return body; + } +} diff --git a/src/app/services/baw-api/security/security.service.spec.ts b/src/app/services/baw-api/security/security.service.spec.ts index cafeec545..209d5db03 100644 --- a/src/app/services/baw-api/security/security.service.spec.ts +++ b/src/app/services/baw-api/security/security.service.spec.ts @@ -1,31 +1,22 @@ import { ok } from "assert"; import { HTTP_INTERCEPTORS } from "@angular/common/http"; -import { TestRequest } from "@angular/common/http/testing"; import { ApiErrorDetails, BawApiInterceptor, } from "@baw-api/api.interceptor.service"; +import { unknownErrorCode } from "@baw-api/baw-api.service"; import { MockShowApiService } from "@baw-api/mock/apiMocks.service"; import { SessionUser, User } from "@models/User"; -import { - createHttpFactory, - HttpMethod, - SpectatorHttp, -} from "@ngneat/spectator"; -import { ConfigService } from "@services/config/config.service"; +import { createHttpFactory, SpectatorHttp } from "@ngneat/spectator"; import { MockAppConfigModule } from "@services/config/configMock.module"; -import { - generateApiErrorDetails, - generateApiErrorResponse, -} from "@test/fakes/ApiErrorDetails"; +import { generateApiErrorDetails } from "@test/fakes/ApiErrorDetails"; import { generateLoginDetails } from "@test/fakes/LoginDetails"; import { generateRegisterDetails } from "@test/fakes/RegisterDetails"; import { generateSessionUser, generateUser } from "@test/fakes/User"; import { modelData } from "@test/helpers/faker"; -import { nStepObservable } from "@test/helpers/general"; +import { getCallArgs, nStepObservable } from "@test/helpers/general"; import { BehaviorSubject, noop, Subject } from "rxjs"; import { - apiErrorDetails, shouldNotComplete, shouldNotFail, shouldNotSucceed, @@ -38,7 +29,6 @@ import { } from "./security.service"; describe("SecurityService", () => { - let apiRoot: string; let defaultUser: User; let defaultSessionUser: SessionUser; let defaultRegisterDetails: RegisterDetails; @@ -66,35 +56,11 @@ describe("SecurityService", () => { return nStepObservable(subject, () => response ?? error, !!error); } - function interceptHtmlRequest(page: any, error?: ApiErrorDetails) { - const spy = jasmine.createSpy("formHtmlRequest"); - spec.service["formHtmlRequest"] = spy; - return intercept(spy, page, error); - } - - function interceptDataRequest(error?: ApiErrorDetails) { - const spy = jasmine.createSpy("formDataRequest"); - spec.service["formDataRequest"] = spy; - return intercept(spy, !error ? "" : undefined, error); - } - - function interceptSessionUser(model: SessionUser, error?: ApiErrorDetails) { - const spy = jasmine.createSpy("apiShow"); - spec.service["apiShow"] = spy; - return intercept(spy, model, error); - } - - function interceptUser(model: User, error?: ApiErrorDetails) { - const spy = spyOn(userApi, "show"); - return intercept(spy, model, error); - } - beforeEach(() => { localStorage.clear(); spec = createService(); userApi = spec.inject(UserService); - apiRoot = spec.inject(ConfigService).environment.apiRoot; defaultAuthToken = modelData.random.alphaNumeric(20); defaultError = generateApiErrorDetails(); @@ -133,79 +99,44 @@ describe("SecurityService", () => { }); describe("signUpSeed", () => { - it("should call formHtmlRequest", () => { - interceptHtmlRequest(""); + it("should call getRecaptchaSeed", () => { + spec.service["getRecaptchaSeed"] = jasmine + .createSpy("getRecaptchaSeed") + .and.callFake(() => new Subject()); + spec.service.signUpSeed().subscribe(noop, noop); - expect(spec.service["formHtmlRequest"]).toHaveBeenCalledWith( + expect(spec.service["getRecaptchaSeed"]).toHaveBeenCalledWith( "/my_account/sign_up/" ); }); - - it("should return recaptcha seed", (done) => { - const seed = modelData.random.alphaNumeric(50); - interceptHtmlRequest( - `` - ); - spec.service.signUpSeed().subscribe((_seed: string) => { - expect(_seed).toBe(seed); - done(); - }, shouldNotFail); - }); - - it("should throw error if page is invalid", (done) => { - interceptHtmlRequest(123456789); - spec.service - .signUpSeed() - .subscribe(shouldNotSucceed, (err: ApiErrorDetails) => { - expect(err.message).toBe("Failed to retrieve auth form"); - done(); - }); - }); - - it("should throw error if no recaptcha seed", (done) => { - interceptHtmlRequest(""); - spec.service - .signUpSeed() - .subscribe(shouldNotSucceed, (err: ApiErrorDetails) => { - expect(err.message).toBe( - "Unable to retrieve recaptcha seed for registration request" - ); - done(); - }); - }); }); describe("authentication methods", () => { - function interceptAuth() { - spec.service["handleAuth"] = jasmine.createSpy("handleAuth").and.stub(); - } + let handleAuthSpy: jasmine.Spy; - function getCallArgs() { - return (spec.service["handleAuth"] as jasmine.Spy).calls.mostRecent() - .args; + function getFormData(token: string): string { + return getCallArgs(handleAuthSpy)[2](token).toString(); } - function getFormDataArgs(): (page: string) => URLSearchParams { - return getCallArgs()[2]; - } + beforeEach(() => { + handleAuthSpy = jasmine.createSpy("handleAuth"); + spec.service["handleAuth"] = handleAuthSpy.and.stub(); + }); describe("signIn", () => { it("should call handleAuth", () => { - interceptAuth(); spec.service.signIn(defaultLoginDetails); - expect(spec.service["handleAuth"]).toHaveBeenCalled(); + expect(handleAuthSpy).toHaveBeenCalled(); }); it("should call handleAuth with correct form endpoint", () => { - interceptAuth(); spec.service.signIn(defaultLoginDetails); - expect(getCallArgs()[0]).toBe("/my_account/sign_in/"); + expect(getCallArgs(handleAuthSpy)[0]).toBe("/my_account/sign_in/"); }); it("should call handleAuth with correct auth endpoint", () => { - interceptAuth(); spec.service.signIn(defaultLoginDetails); - expect(getCallArgs()[1]).toBe("/my_account/sign_in/"); + expect(getCallArgs(handleAuthSpy)[1]).toBe("/my_account/sign_in/"); }); it("should set request body", () => { @@ -213,7 +144,6 @@ describe("SecurityService", () => { login: "sign_in details", password: "sign_in password", }); - const page = ``; const expectation = "user%5Blogin%5D=sign_in+details&" + "user%5Bpassword%5D=sign_in+password&" + @@ -221,37 +151,25 @@ describe("SecurityService", () => { "commit=Log%2Bin&" + `authenticity_token=${defaultAuthToken}`; - interceptAuth(); spec.service.signIn(loginDetails); - expect(getFormDataArgs()(page).toString()).toBe(expectation); - }); - - it("should throw error if no authenticity token", () => { - interceptAuth(); - spec.service.signIn(defaultLoginDetails); - expect(function () { - getFormDataArgs()(""); - }).toThrowError(); + expect(getFormData(defaultAuthToken)).toBe(expectation); }); }); describe("signUp", () => { it("should call handleAuth", () => { - interceptAuth(); spec.service.signUp(defaultRegisterDetails); - expect(spec.service["handleAuth"]).toHaveBeenCalled(); + expect(handleAuthSpy).toHaveBeenCalled(); }); it("should call handleAuth with correct form endpoint", () => { - interceptAuth(); spec.service.signUp(defaultRegisterDetails); - expect(getCallArgs()[0]).toBe("/my_account/sign_up/"); + expect(getCallArgs(handleAuthSpy)[0]).toBe("/my_account/sign_up/"); }); it("should call handleAuth with correct auth endpoint", () => { - interceptAuth(); spec.service.signUp(defaultRegisterDetails); - expect(getCallArgs()[1]).toBe("/my_account/"); + expect(getCallArgs(handleAuthSpy)[1]).toBe("/my_account/"); }); it("should set request body", () => { @@ -262,7 +180,6 @@ describe("SecurityService", () => { passwordConfirmation: "sign_up password", recaptchaToken: "xxxxxxxxxx", }); - const page = ``; const expectation = "user%5Buser_name%5D=sign_up+details&" + "user%5Bemail%5D=sign_up%40email.com&" + @@ -273,17 +190,30 @@ describe("SecurityService", () => { "g-recaptcha-response-data%5Bregister%5D=xxxxxxxxxx&" + "g-recaptcha-response="; - interceptAuth(); spec.service.signUp(registerDetails); - expect(getFormDataArgs()(page).toString()).toBe(expectation); + expect(getFormData(defaultAuthToken)).toBe(expectation); }); - it("should throw error if no authenticity token", () => { - interceptAuth(); + it("should throw error if username is not unique", () => { + const response = + '' + + 'has already been taken'; + spec.service.signUp(defaultRegisterDetails); expect(function () { - getFormDataArgs()(""); - }).toThrowError(); + getCallArgs(handleAuthSpy)[3](response); + }).toThrowError("Username has already been taken."); + }); + + it("should throw error if email is not unique", () => { + const response = + '' + + 'has already been taken'; + + spec.service.signUp(defaultRegisterDetails); + expect(function () { + getCallArgs(handleAuthSpy)[3](response); + }).toThrowError("Email address has already been taken."); }); it("should throw error if no recaptcha token", () => { @@ -293,219 +223,177 @@ describe("SecurityService", () => { }); const page = ``; - interceptAuth(); spec.service.signUp(registerDetails); expect(function () { - getFormDataArgs()(page); + getFormData(page); }).toThrowError(); }); }); }); describe("handleAuth", () => { + let makeFormRequestSpy: jasmine.Spy; + let sessionUserSpy: jasmine.Spy; + let userSpy: jasmine.Spy; + function handleAuth(inputs?: { formEndpoint?: string; authEndpoint?: string; getFormData?: (page: string) => URLSearchParams; - next?: (value: string) => void; - error?: (error: any) => void; - complete?: () => void; + pageValidation?: (page: string) => void; }) { - spec.service["handleAuth"]( + return spec.service["handleAuth"]( inputs?.formEndpoint ?? "/broken_link", inputs?.authEndpoint ?? "/broken_link", - inputs?.getFormData ?? (() => new URLSearchParams()) - ).subscribe( - inputs?.next ?? noop, - inputs?.error ?? noop, - inputs?.complete + inputs?.getFormData ?? (() => new URLSearchParams()), + inputs?.pageValidation ?? (() => {}) ); } - describe("1st Request: formHtmlRequest", () => { - beforeEach(() => { - interceptDataRequest(); - interceptSessionUser(defaultSessionUser); - interceptUser(defaultUser); - }); - - it("should call formHtmlRequest", async () => { - interceptHtmlRequest(""); - handleAuth({ formEndpoint: "/form_html_link" }); - expect(spec.service["formHtmlRequest"]).toHaveBeenCalled(); - }); + function interceptSessionUser(model: SessionUser, error?: ApiErrorDetails) { + sessionUserSpy = jasmine.createSpy("apiShow"); + spec.service["apiShow"] = sessionUserSpy; + return intercept(sessionUserSpy, model, error); + } - it("should call formHtmlRequest with path", async () => { - interceptHtmlRequest(""); - handleAuth({ formEndpoint: "/form_html_link" }); - expect(spec.service["formHtmlRequest"]).toHaveBeenCalledWith( - "/form_html_link" - ); - }); + function interceptUser(model: User, error?: ApiErrorDetails) { + userSpy = spyOn(userApi, "show"); + return intercept(userSpy, model, error); + } - it("should handle invalid formHtmlRequest response", (done) => { - interceptHtmlRequest(123456789); - handleAuth({ - error: (err: ApiErrorDetails) => { - expect(err.message).toEqual("Failed to retrieve auth form"); - done(); - }, - }); - }); + function interceptMakeFormRequest(error?: ApiErrorDetails) { + makeFormRequestSpy = jasmine.createSpy("makeFormRequest"); + spec.service["makeFormRequest"] = makeFormRequestSpy; + return intercept( + makeFormRequestSpy, + !error ? "" : undefined, + error + ); + } - it("should handle formHtmlRequest failure", (done) => { - interceptHtmlRequest(undefined, defaultError); - handleAuth({ - error: (err) => { - expect(err).toEqual(defaultError); - done(); - }, - }); + describe("1st Request: makeFormRequest", () => { + it("should call makeFormRequest", () => { + interceptMakeFormRequest(); + handleAuth().subscribe(noop, noop); + expect(makeFormRequestSpy).toHaveBeenCalled(); }); - }); - - describe("2nd Request: formDataRequest", () => { - let initialRequests: Promise; - beforeEach(() => { - initialRequests = interceptHtmlRequest(""); - interceptSessionUser(defaultSessionUser); - interceptUser(defaultUser); + it("should call makeFormRequest with formEndpoint", () => { + interceptMakeFormRequest(); + handleAuth({ formEndpoint: "/form_html_link" }).subscribe(noop, noop); + expect(getCallArgs(makeFormRequestSpy)[0]).toBe("/form_html_link"); }); - it("should call formDataRequest", async () => { - const formData = new URLSearchParams({ test: "example" }); - interceptDataRequest(); - handleAuth({ - authEndpoint: "/form_data_link", - getFormData: () => formData, - }); - await initialRequests; - expect(spec.service["formDataRequest"]).toHaveBeenCalled(); + it("should call makeFormRequest with authEndpoint", () => { + interceptMakeFormRequest(); + handleAuth({ authEndpoint: "/auth_html_link" }).subscribe(noop, noop); + expect(getCallArgs(makeFormRequestSpy)[1]).toBe("/auth_html_link"); }); - it("should call formDataRequest with path and formData", async () => { - const formData = new URLSearchParams({ test: "example" }); - interceptDataRequest(); - handleAuth({ - authEndpoint: "/form_data_link", - getFormData: () => formData, - }); - await initialRequests; - expect(spec.service["formDataRequest"]).toHaveBeenCalledWith( - "/form_data_link", - formData - ); + it("should call makeFormRequest with getFormData", () => { + const formData = () => new URLSearchParams(); + interceptMakeFormRequest(); + handleAuth({ getFormData: formData }).subscribe(noop, noop); + expect(getCallArgs(makeFormRequestSpy)[2]).toBe(formData); }); - it("should handle getFormData throwing error", (done) => { - interceptDataRequest(); + it("should perform additional page validation", (done) => { + interceptMakeFormRequest(); handleAuth({ - getFormData: () => { - throw new Error("custom error message"); - }, - error: (err: ApiErrorDetails) => { - expect(err.message).toBe("custom error message"); - done(); + pageValidation: () => { + throw Error("custom error"); }, + }).subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual({ + status: unknownErrorCode, + message: "custom error", + } as ApiErrorDetails); + done(); }); }); - it("should handle formDataRequest failure", (done) => { - interceptDataRequest(defaultError); - handleAuth({ - error: (err: ApiErrorDetails) => { - expect(err).toEqual(defaultError); - done(); - }, + it("should handle makeFormRequest failure", (done) => { + interceptMakeFormRequest(defaultError); + handleAuth().subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual(defaultError); + done(); }); }); }); - describe("3rd Request: Get session user", () => { - let initialRequests: Promise; + describe("2nd Request: Get session user", () => { + let initialSteps: Promise; beforeEach(() => { - initialRequests = Promise.all([ - interceptHtmlRequest(""), - interceptDataRequest(), - ]); - interceptUser(defaultUser); + initialSteps = interceptMakeFormRequest(); }); it("should request session user details", async () => { interceptSessionUser(defaultSessionUser); - handleAuth(); - await initialRequests; - expect(spec.service["apiShow"]).toHaveBeenCalled(); + handleAuth().subscribe(noop, noop); + await initialSteps; + expect(sessionUserSpy).toHaveBeenCalled(); }); it("should request session user details with anti cache timestamp", async () => { interceptSessionUser(defaultSessionUser); - handleAuth(); - await initialRequests; + handleAuth().subscribe(noop, noop); + await initialSteps; // Validate timestamp within 1000 ms const timestamp = Math.floor(Date.now() / 1000); - const argument = (spec.service[ - "apiShow" - ] as jasmine.Spy).calls.mostRecent().args[0]; - expect(argument).toContain("/security/user?antiCache=" + timestamp); + expect(getCallArgs(sessionUserSpy)[0]).toContain( + "/security/user?antiCache=" + timestamp + ); }); it("should handle session user details failure", (done) => { interceptSessionUser(undefined, defaultError); - handleAuth({ - error: (err: ApiErrorDetails) => { - expect(err).toEqual(defaultError); - done(); - }, + handleAuth().subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual(defaultError); + done(); }); }); }); - describe("4th Request: Get user", () => { - let initialRequests: Promise; + describe("3rd Request: Get user", () => { + let initialSteps: Promise; beforeEach(() => { - initialRequests = Promise.all([ - interceptHtmlRequest(""), - interceptDataRequest(), + initialSteps = Promise.all([ + interceptMakeFormRequest(), interceptSessionUser(defaultSessionUser), ]); }); it("should request user details", async () => { interceptUser(defaultUser); - handleAuth(); - await initialRequests; - expect(userApi.show).toHaveBeenCalled(); + handleAuth().subscribe(noop, noop); + await initialSteps; + expect(userSpy).toHaveBeenCalled(); }); it("should handle user details failure", (done) => { interceptUser(undefined, defaultError); - handleAuth({ - error: (err: ApiErrorDetails) => { - expect(err).toEqual(defaultError); - done(); - }, + handleAuth().subscribe(shouldNotSucceed, (err) => { + expect(err).toEqual(defaultError); + done(); }); }); }); describe("success", () => { - async function interceptRequests() { + async function initialSteps() { return Promise.all([ - interceptHtmlRequest(""), - interceptDataRequest(), + interceptMakeFormRequest(), interceptSessionUser(defaultSessionUser), interceptUser(defaultUser), ]); } it("should store session user in local storage", async () => { - const promise = interceptRequests(); - handleAuth(); + const promise = initialSteps(); + handleAuth().subscribe(noop, noop); await promise; expect(spec.service.getLocalUser()).toEqual( new SessionUser({ @@ -520,31 +408,25 @@ describe("SecurityService", () => { trigger.subscribe(noop, noop, shouldNotComplete); spyOn(trigger, "next").and.callThrough(); expect(trigger.next).toHaveBeenCalledTimes(0); - const promise = interceptRequests(); - handleAuth(); + const promise = initialSteps(); + handleAuth().subscribe(noop, noop); await promise; expect(trigger.next).toHaveBeenCalledTimes(1); }); it("should call next", (done) => { - interceptRequests(); - handleAuth({ - next: () => { - expect(true).toBeTrue(); - done(); - }, - error: shouldNotFail, - }); + initialSteps(); + handleAuth().subscribe(() => { + expect(true).toBeTrue(); + done(); + }, shouldNotFail); }); it("should complete observable", (done) => { - interceptRequests(); - handleAuth({ - error: shouldNotFail, - complete: () => { - expect(true).toBeTrue(); - done(); - }, + initialSteps(); + handleAuth().subscribe(noop, shouldNotFail, () => { + expect(true).toBeTrue(); + done(); }); }); }); @@ -553,137 +435,14 @@ describe("SecurityService", () => { it("should call clearData", async () => { spec.service["clearData"] = jasmine.createSpy("clearData").and.stub(); spec.service["storeLocalUser"](defaultSessionUser); - const promise = interceptHtmlRequest(undefined, defaultError); - handleAuth(); + const promise = interceptMakeFormRequest(defaultError); + handleAuth().subscribe(noop, noop); await promise; expect(spec.service["clearData"]).toHaveBeenCalled(); }); }); }); - describe("formHtmlRequest", () => { - function interceptRequest(path: string) { - return spec.expectOne(apiRoot + path, HttpMethod.GET); - } - - function formHtmlRequest( - path: string, - next: (value: string) => void = noop, - error: (error: any) => void = noop - ) { - spec.service["formHtmlRequest"](path).subscribe(next, error); - } - - it("should create get request", () => { - formHtmlRequest("/broken_link"); - expect(interceptRequest("/broken_link")).toBeInstanceOf(TestRequest); - }); - - it("should set responseType to text", () => { - formHtmlRequest("/broken_link"); - const responseType = interceptRequest("/broken_link").request - .responseType; - expect(responseType).toBe("text"); - }); - - it("should set accept header to text/html", () => { - formHtmlRequest("/broken_link"); - const headers = interceptRequest("/broken_link").request.headers; - expect(headers.get("Accept")).toBe("text/html"); - }); - - it("should not set content type headers", () => { - formHtmlRequest("/broken_link"); - const headers = interceptRequest("/broken_link").request.headers; - expect(headers.get("Content-Type")).not.toBeTruthy(); - }); - - it("should return page contents", (done) => { - const response = ""; - formHtmlRequest("/broken_link", (page) => { - expect(page).toBe(response); - done(); - }); - interceptRequest("/broken_link").flush(response); - }); - - it("should handle api error", (done) => { - formHtmlRequest("/broken_link", shouldNotSucceed, (err) => { - expect(err?.status).toBe(500); - done(); - }); - interceptRequest("/broken_link").flush( - generateApiErrorResponse("Internal Server Error"), - { status: 500, statusText: "Internal Server Error" } - ); - }); - }); - - describe("formDataRequest", () => { - function interceptRequest(path: string) { - return spec.expectOne(apiRoot + path, HttpMethod.POST); - } - - function formDataRequest( - path: string, - formData: URLSearchParams = new URLSearchParams(), - next: (value: string) => void = noop, - error: (error: any) => void = noop - ) { - spec.service["formDataRequest"](path, formData).subscribe(next, error); - } - - it("should create post request", () => { - formDataRequest("/broken_link"); - expect(interceptRequest("/broken_link")).toBeInstanceOf(TestRequest); - }); - - it("should set responseType to text", () => { - formDataRequest("/broken_link"); - const responseType = interceptRequest("/broken_link").request - .responseType; - expect(responseType).toBe("text"); - }); - - it("should set accept header to text/html", () => { - formDataRequest("/broken_link"); - const headers = interceptRequest("/broken_link").request.headers; - expect(headers.get("Accept")).toBe("text/html"); - }); - - it("should set content type header to form-urlencoded", () => { - formDataRequest("/broken_link"); - const headers = interceptRequest("/broken_link").request.headers; - expect(headers.get("Content-Type")).toBe( - "application/x-www-form-urlencoded" - ); - }); - - it("should insert form data", () => { - const formData = new URLSearchParams({ - "user[login]": "example username", - "user[password]": "Ex@mp1e_P@55w0rd+=", - }); - formDataRequest("/broken_link", formData); - const body = interceptRequest("/broken_link").request.body; - expect(body).toBe( - "user%5Blogin%5D=example+username&" + - "user%5Bpassword%5D=Ex%40mp1e_P%4055w0rd%2B%3D" - ); - }); - - it("should handle api error", (done) => { - formDataRequest("/broken_link", undefined, shouldNotSucceed, (err) => { - expect(err?.status).toBe(500); - done(); - }); - interceptRequest("/broken_link").flush( - generateApiErrorResponse("Internal Server Error"), - { status: 500, statusText: "Internal Server Error" } - ); - }); - }); - describe("clearData", () => { beforeEach(() => { spec.service["clearSessionUser"] = jasmine @@ -766,22 +525,23 @@ describe("SecurityService", () => { }); it("should handle error", () => { - createError("/security/", apiErrorDetails); + const error = generateApiErrorDetails(); + createError("/security/", error); spec.service.signOut().subscribe(shouldNotSucceed, (err) => { - expect(err).toEqual(apiErrorDetails); + expect(err).toEqual(error); }); }); it("should clear cookies", () => { spyOn(spec.service["cookies"], "deleteAll").and.stub(); - createError("/security/", apiErrorDetails); + createError("/security/", generateApiErrorDetails()); spec.service.signOut().subscribe(noop, noop); expect(spec.service["cookies"].deleteAll).toHaveBeenCalledTimes(1); }); it("should trigger authTrigger on error", () => { const spy = jasmine.createSpy(); - createError("/security/", apiErrorDetails); + createError("/security/", generateApiErrorDetails()); spec.service .getAuthTrigger() diff --git a/src/app/services/baw-api/security/security.service.ts b/src/app/services/baw-api/security/security.service.ts index 21f9f4120..8be6b016e 100644 --- a/src/app/services/baw-api/security/security.service.ts +++ b/src/app/services/baw-api/security/security.service.ts @@ -1,17 +1,17 @@ -import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import { Inject, Injectable, Injector } from "@angular/core"; import { param } from "@baw-api/api-common"; +import { BawFormApiService } from "@baw-api/baw-form-api.service"; import { API_ROOT } from "@helpers/app-initializer/app-initializer"; -import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; import { stringTemplate } from "@helpers/stringTemplate/stringTemplate"; -import { AbstractModel } from "@models/AbstractModel"; +import { Param, UserName } from "@interfaces/apiInterfaces"; +import { AbstractForm } from "@models/AbstractForm"; import { bawPersistAttr } from "@models/AttributeDecorators"; import { SessionUser, User } from "@models/User"; import { CookieService } from "ngx-cookie-service"; -import { BehaviorSubject, Observable, ObservableInput, throwError } from "rxjs"; -import { catchError, map, mergeMap, take, tap } from "rxjs/operators"; +import { BehaviorSubject, Observable } from "rxjs"; +import { catchError, first, map, mergeMap, tap } from "rxjs/operators"; import { ApiErrorDetails } from "../api.interceptor.service"; -import { apiReturnCodes, BawApiService } from "../baw-api.service"; import { UserService } from "../user/user.service"; const signUpSeed = stringTemplate`/my_account/sign_up/`; @@ -25,21 +25,8 @@ const sessionUserEndpoint = stringTemplate`/security/user?antiCache=${param}`; * Handles API routes pertaining to security. */ @Injectable() -export class SecurityService extends BawApiService { +export class SecurityService extends BawFormApiService { private authTrigger = new BehaviorSubject(null); - private handleError = ( - err: ApiErrorDetails | Error - ): ObservableInput => { - this.clearData(); - - if (err instanceof Error) { - return throwError({ - status: apiReturnCodes.unknown, - message: err.message, - } as ApiErrorDetails); - } - return throwError(err); - }; public constructor( http: HttpClient, @@ -49,6 +36,12 @@ export class SecurityService extends BawApiService { injector: Injector ) { super(http, apiRoot, SessionUser, injector); + + // After constructor so that we can access super + this.handleError = (err: ApiErrorDetails | Error): Observable => { + this.clearData(); + return super.handleError(err); + }; } /** @@ -61,32 +54,8 @@ export class SecurityService extends BawApiService { /** * Returns the recaptcha seed for the registration form */ - public signUpSeed(): Observable { - return this.formHtmlRequest(signUpSeed()).pipe( - // Validate api response, and get form data if valid - tap((page: any) => { - if (typeof page !== "string") { - throw new Error("Failed to retrieve auth form"); - } - }), - // Extract token from page - map((page: string) => - page.match( - /id="g-recaptcha-response-data-register" data-sitekey="(.+?)"/ - ) - ), - // Return token if exists - map((token: RegExpMatchArray) => { - if (isInstantiated(token?.[1])) { - return token[1]; - } - throw new Error( - "Unable to retrieve recaptcha seed for registration request" - ); - }), - // Handle errors - catchError(this.handleError) - ); + public signUpSeed() { + return this.getRecaptchaSeed(signUpSeed()); } /** @@ -95,33 +64,33 @@ export class SecurityService extends BawApiService { * @param details Details provided by registration form */ public signUp(details: RegisterDetails): Observable { - return this.handleAuth(signUpSeed(), signUpEndpoint(), (page: string) => { - // Extract auth token if exists - const token = page.match(/name="authenticity_token" value="(.+?)"/); - if (!isInstantiated(token?.[1])) { - throw new Error( - "Unable to retrieve authenticity token for sign up request" - ); + // Read page response for unique username error + const validateUniqueUsername = (page: string) => { + const errMsg = + 'id="user_user_name" />has already been taken'; + if (page.includes(errMsg)) { + throw Error("Username has already been taken."); } - - if (!isInstantiated(details.recaptchaToken)) { - throw new Error( - "Unable to retrieve recaptcha token for sign up request" - ); + }; + + // Read page response for unique email error + const validateUniqueEmail = (page: string) => { + const errMsg = + 'id="user_email" />has already been taken'; + if (page.includes(errMsg)) { + throw Error("Email address has already been taken."); } + }; - // Set form data - const body = new URLSearchParams(); - body.set("user[user_name]", details.userName); - body.set("user[email]", details.email); - body.set("user[password]", details.password); - body.set("user[password_confirmation]", details.passwordConfirmation); - body.set("commit", "Register"); - body.set("authenticity_token", token[1]); - body.set("g-recaptcha-response-data[register]", details.recaptchaToken); - body.set("g-recaptcha-response", ""); - return body; - }); + return this.handleAuth( + signUpSeed(), + signUpEndpoint(), + (token: string) => details.getBody(token), + (page) => { + validateUniqueUsername(page); + validateUniqueEmail(page); + } + ); } /** @@ -133,24 +102,7 @@ export class SecurityService extends BawApiService { return this.handleAuth( signInEndpoint(), signInEndpoint(), - (page: string) => { - // Extract auth token if exists - const token = page.match(/name="authenticity_token" value="(.+?)"/); - if (!isInstantiated(token?.[1])) { - throw new Error( - "Unable to retrieve authenticity token for sign in request" - ); - } - - // Set form data - const body = new URLSearchParams(); - body.set("user[login]", details.login); - body.set("user[password]", details.password); - body.set("user[remember_me]", "0"); - body.set("commit", "Log+in"); - body.set("authenticity_token", token[1]); - return body; - } + (token: string) => details.getBody(token) ); } @@ -161,7 +113,7 @@ export class SecurityService extends BawApiService { return this.apiDestroy(signOutEndpoint()).pipe( tap(() => this.clearData()), catchError(this.handleError) - ); + ) as Observable; } /** @@ -174,26 +126,17 @@ export class SecurityService extends BawApiService { private handleAuth( formEndpoint: string, authEndpoint: string, - getFormData: (page: string) => URLSearchParams - ) { - // Request form page - return this.formHtmlRequest(formEndpoint).pipe( - // Validate api response, and get form data if valid - map((page: any) => { - if (typeof page !== "string") { - throw new Error("Failed to retrieve auth form"); - } - return getFormData(page); - }), - /* - * Mimic a traditional form-based sign in/sign up to get a well-formed auth cookie - * Needed because of: - * - https://github.com/QutEcoacoustics/baw-server/issues/509 - * - https://github.com/QutEcoacoustics/baw-server/issues/424 - */ - mergeMap((formData: URLSearchParams) => - this.formDataRequest(authEndpoint, formData) - ), + getFormData: (authToken: string) => URLSearchParams, + pageValidation: (page: string) => void = () => {} + ): Observable { + /* + * Mimic a traditional form-based sign in/sign up to get a well-formed auth cookie + * Needed because of: + * - https://github.com/QutEcoacoustics/baw-server/issues/509 + * - https://github.com/QutEcoacoustics/baw-server/issues/424 + */ + return this.makeFormRequest(formEndpoint, authEndpoint, getFormData).pipe( + tap((page) => pageValidation(page)), // Trade the cookie for an API auth token (mimicking old baw-client) mergeMap(() => this.apiShow(sessionUserEndpoint(Date.now().toString()))), // Save to local storage @@ -208,10 +151,14 @@ export class SecurityService extends BawApiService { ), // Trigger auth observable tap(() => this.authTrigger.next(null)), + // Void output + map(() => undefined), // Complete observable - take(1), - // Handle errors - catchError(this.handleError) + first(), + catchError((err) => { + this.clearData(); + return this.handleError(err); + }) ); } @@ -223,89 +170,67 @@ export class SecurityService extends BawApiService { this.cookies.deleteAll(); this.authTrigger.next(null); } - - /** - * Request a HTML page from the API. This will be used to extract important - * information required to make form-based requests later on - */ - private formHtmlRequest(path: string): Observable { - return this.http.get(this.getPath(path), { - responseType: "text", - // eslint-disable-next-line @typescript-eslint/naming-convention - headers: new HttpHeaders({ Accept: "text/html" }), - }); - } - - /** - * Use the form-based request to authenticate. The server will issue a - * traditional session cookie which we can later trade for an auth token - * - * @param path API route path - * @param formData API body - */ - private formDataRequest( - path: string, - formData: URLSearchParams - ): Observable { - return this.http.post(this.getPath(path), formData.toString(), { - responseType: "text", - headers: new HttpHeaders({ - // eslint-disable-next-line @typescript-eslint/naming-convention - Accept: "text/html", - // eslint-disable-next-line @typescript-eslint/naming-convention - "Content-Type": "application/x-www-form-urlencoded", - }), - }); - } } export interface ILoginDetails { - login?: string; - password?: string; + login?: Param; + password?: Param; } -export class LoginDetails extends AbstractModel implements ILoginDetails { +export class LoginDetails + extends AbstractForm + implements ILoginDetails { public readonly kind = "LoginDetails"; @bawPersistAttr - public readonly login: string; + public readonly login: Param; @bawPersistAttr - public readonly password: string; - - public constructor(details: ILoginDetails) { - super(details); - } - - public get viewUrl(): string { - throw new Error("Not Implemented"); + public readonly password: Param; + + public getBody(token: string): URLSearchParams { + const body = new URLSearchParams(); + body.set("user[login]", this.login); + body.set("user[password]", this.password); + body.set("user[remember_me]", "0"); + body.set("commit", "Log+in"); + body.set("authenticity_token", token); + return body; } } export interface IRegisterDetails { - userName: string; - email: string; - password: string; - passwordConfirmation: string; + userName: UserName; + email: Param; + password: Param; + passwordConfirmation: Param; recaptchaToken: string; } -export class RegisterDetails extends AbstractModel implements IRegisterDetails { +export class RegisterDetails + extends AbstractForm + implements IRegisterDetails { public readonly kind = "RegisterDetails"; @bawPersistAttr - public readonly userName: string; + public readonly userName: UserName; @bawPersistAttr - public readonly email: string; + public readonly email: Param; @bawPersistAttr - public readonly password: string; + public readonly password: Param; @bawPersistAttr - public readonly passwordConfirmation: string; + public readonly passwordConfirmation: Param; @bawPersistAttr public readonly recaptchaToken: string; - public constructor(details: IRegisterDetails) { - super(details); - } - - public get viewUrl(): string { - throw new Error("Not Implemented"); + public getBody(token: string): URLSearchParams { + this.validateRecaptchaToken(); + const body = new URLSearchParams(); + body.set("user[user_name]", this.userName); + body.set("user[email]", this.email); + body.set("user[password]", this.password); + body.set("user[password_confirmation]", this.passwordConfirmation); + body.set("commit", "Register"); + body.set("authenticity_token", token); + body.set("g-recaptcha-response-data[register]", this.recaptchaToken); + body.set("g-recaptcha-response", ""); + return body; } } diff --git a/src/app/test/fakes/ApiErrorDetails.ts b/src/app/test/fakes/ApiErrorDetails.ts index ced270408..af86aadbf 100644 --- a/src/app/test/fakes/ApiErrorDetails.ts +++ b/src/app/test/fakes/ApiErrorDetails.ts @@ -1,6 +1,8 @@ import { ApiErrorDetails } from "@baw-api/api.interceptor.service"; -import { ApiResponse, apiReturnCodes } from "@baw-api/baw-api.service"; +import { ApiResponse, unknownErrorCode } from "@baw-api/baw-api.service"; +import httpCodes from "http-status"; +// TODO Replace this with http-status numbers instead of string typing type HTTPStatus = | "Unauthorized" | "Not Found" @@ -25,29 +27,29 @@ export function generateApiErrorDetails( switch (type) { case "Unauthorized": - status = apiReturnCodes.unauthorized; + status = httpCodes.UNAUTHORIZED; message = type; break; case "Not Found": - status = apiReturnCodes.notFound; + status = httpCodes.NOT_FOUND; message = type; break; case "Bad Request": - status = apiReturnCodes.badRequest; + status = httpCodes.BAD_REQUEST; message = type; break; case "Unprocessable Entity": - status = apiReturnCodes.unprocessableEntity; + status = httpCodes.UNPROCESSABLE_ENTITY; message = "Record could not be saved"; break; case "Internal Server Error": - status = apiReturnCodes.internalServerFailure; + status = httpCodes.INTERNAL_SERVER_ERROR; message = "Internal Server Failure"; break; } return { - status: custom?.status ?? status ?? apiReturnCodes.unknown, + status: custom?.status ?? status ?? unknownErrorCode, message: custom?.message ?? message ?? "Unknown", info: custom?.info ?? undefined, }; diff --git a/src/app/test/fakes/User.ts b/src/app/test/fakes/User.ts index 780f62f2e..0976f11fd 100644 --- a/src/app/test/fakes/User.ts +++ b/src/app/test/fakes/User.ts @@ -35,7 +35,7 @@ export function generateUser(id?: Id, isAdmin?: boolean): Required { export function generateSessionUser(): ISessionUser { return { - authToken: modelData.random.alphaNumeric(20), + authToken: modelData.authToken(), userName: modelData.internet.userName(), }; } diff --git a/src/app/test/helpers/faker.ts b/src/app/test/helpers/faker.ts index c217dc11d..74b3c9823 100644 --- a/src/app/test/helpers/faker.ts +++ b/src/app/test/helpers/faker.ts @@ -16,6 +16,7 @@ export const modelData = { AccessLevel.writer, AccessLevel.owner, ]), + authToken: () => faker.random.alphaNumeric(20), bool: () => faker.random.boolean(), description: () => faker.lorem.sentence().replace(specialCharRegex, ""), descriptionLong: () => diff --git a/src/app/test/helpers/general.ts b/src/app/test/helpers/general.ts index 1e2aa342b..cd65b7606 100644 --- a/src/app/test/helpers/general.ts +++ b/src/app/test/helpers/general.ts @@ -100,3 +100,7 @@ export type FilterExpectations = ( filter: Filters, ...params: any[] ) => void; + +export function getCallArgs(spy: jasmine.Spy) { + return spy.calls.mostRecent().args; +} diff --git a/src/app/test/helpers/html.ts b/src/app/test/helpers/html.ts index f56d892a3..ab66ab88e 100644 --- a/src/app/test/helpers/html.ts +++ b/src/app/test/helpers/html.ts @@ -1,7 +1,6 @@ import { DebugElement } from "@angular/core"; import { ComponentFixture, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { Params } from "@angular/router"; import { AuthenticatedImageDirective } from "@directives/image/image.directive"; import { LineTruncationDirective } from "ngx-line-truncation"; diff --git a/src/app/test/helpers/paginationTemplate.ts b/src/app/test/helpers/paginationTemplate.ts index 8025be931..8c232a5ec 100644 --- a/src/app/test/helpers/paginationTemplate.ts +++ b/src/app/test/helpers/paginationTemplate.ts @@ -1,11 +1,9 @@ -import { defaultApiPageSize } from "@baw-api/baw-api.service"; import { PaginationTemplate } from "@helpers/paginationTemplate/paginationTemplate"; import { AbstractModel } from "@models/AbstractModel"; import { NgbPagination } from "@ng-bootstrap/ng-bootstrap"; import { Spectator } from "@ngneat/spectator"; import { DebounceInputComponent } from "@shared/debounce-input/debounce-input.component"; import { LoadingComponent } from "@shared/loading/loading.component"; -import { createScanner } from "typescript"; export function assertPaginationTemplate< M extends AbstractModel, diff --git a/src/app/test/helpers/testbed.ts b/src/app/test/helpers/testbed.ts index 8ee8c3284..dfbe3635a 100644 --- a/src/app/test/helpers/testbed.ts +++ b/src/app/test/helpers/testbed.ts @@ -11,6 +11,7 @@ import { NgbModule } from "@ng-bootstrap/ng-bootstrap"; import { FormlyBootstrapModule } from "@ngx-formly/bootstrap"; import { FormlyModule } from "@ngx-formly/core"; import { LoadingModule } from "@shared/loading/loading.module"; +import { NgxCaptchaModule } from "ngx-captcha"; import { ToastrModule } from "ngx-toastr"; import { BehaviorSubject } from "rxjs"; import { formlyRoot, toastrRoot } from "src/app/app.helper"; @@ -27,6 +28,7 @@ export const testFormImports = [ HttpClientTestingModule, RouterTestingModule, LoadingModule, + NgxCaptchaModule, ]; /**