diff --git a/package-lock.json b/package-lock.json index 68889af..a9a2580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,10 @@ "@angular/platform-browser-dynamic": "^17.2.3", "@angular/router": "^17.2.3", "buffer": "^6.0.3", + "rsocket-adapter-rxjs": "^1.0.0-alpha.4", "rsocket-composite-metadata": "^1.0.0-alpha.3", "rsocket-core": "^1.0.0-alpha.3", + "rsocket-messaging": "^1.0.0-alpha.3", "rsocket-websocket-client": "^1.0.0-alpha.3", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -44,7 +46,6 @@ "eslint-config-prettier": "^9.0.0", "husky": "^8.0.3", "jasmine-core": "~4.6.0", - "jasmine-marbles": "^0.9.2", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", @@ -54,7 +55,7 @@ "stylelint": "^15.11.0", "stylelint-config-standard": "^34.0.0", "stylelint-config-standard-scss": "^11.0.0", - "typescript": "~5.2.2" + "typescript": "~5.3.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -80,12 +81,12 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1702.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1702.2.tgz", - "integrity": "sha512-qBvif8/NquFUqVQgs4U+8wXh/rQZv+YlYwg6eDZly1bIaTd/k9spko/seTtNT1OpK/Be+GLo5IbiQ7i2SON3iQ==", + "version": "0.1702.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1702.3.tgz", + "integrity": "sha512-4jeBgtBIZxAeJyiwSdbRE4+rWu34j0UMCKia8s7473rKj0Tn4+dXlHmA/kuFYIp6K/9pE/hBoeUFxLNA/DZuRQ==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.2.2", + "@angular-devkit/core": "17.2.3", "rxjs": "7.8.1" }, "engines": { @@ -95,15 +96,15 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.2.2.tgz", - "integrity": "sha512-K55xBiWBfxD4wmxLR2viOPbBryOk6YaZeNr72IMkp1yIrIy1BES6LDJi7R9fDW7+TprqZdM4B91Tkc+BCwYQzQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.2.3.tgz", + "integrity": "sha512-AZsEHZj+k2Lxb7uQUwfEpSE6TvQhCoIgP6XLKgKxZHUOiTUVXDj84WhNcbup5SsSG1cafmoVN7APxxuSwHcoeg==", "dev": true, "dependencies": { "@ampproject/remapping": "2.2.1", - "@angular-devkit/architect": "0.1702.2", - "@angular-devkit/build-webpack": "0.1702.2", - "@angular-devkit/core": "17.2.2", + "@angular-devkit/architect": "0.1702.3", + "@angular-devkit/build-webpack": "0.1702.3", + "@angular-devkit/core": "17.2.3", "@babel/core": "7.23.9", "@babel/generator": "7.23.6", "@babel/helper-annotate-as-pure": "7.22.5", @@ -114,7 +115,7 @@ "@babel/preset-env": "7.23.9", "@babel/runtime": "7.23.9", "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.2.2", + "@ngtools/webpack": "17.2.3", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.17", @@ -223,64 +224,29 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true - }, - "node_modules/@angular-devkit/build-angular/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/picomatch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", - "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "node_modules/@angular-devkit/build-angular/node_modules/@ngtools/webpack": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.2.3.tgz", + "integrity": "sha512-+d5Q7/ctDHePYZXcg0GFwL/AbyEkPMHoCiT7pmLI0B0n87D/mYKK/qmVN1VANBrFLTuIe8RtcL0aJ9pw8HAxWA==", "dev": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@angular/compiler-cli": "^17.0.0", + "typescript": ">=5.2 <5.4", + "webpack": "^5.54.0" } }, - "node_modules/@angular-devkit/build-angular/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1702.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1702.2.tgz", - "integrity": "sha512-+c7rHD2Se1VD9i9uPEYHqhq8hTnsUAn5LfeJCLS8g7FU8T42tDSC/k1qWxHp7d99kf7ecg2BvYcZDlYaBUnl3A==", + "version": "0.1702.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1702.3.tgz", + "integrity": "sha512-G9F2Ori8WxJtMvOQGxTdg7d+5aAO1IPeEtMiZwFPrw65Ey6Gvfm0h2+3FnQdzeKrZmGaTk5E6gffHXJJQfCnmQ==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1702.2", + "@angular-devkit/architect": "0.1702.3", "rxjs": "7.8.1" }, "engines": { @@ -294,9 +260,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.2.2.tgz", - "integrity": "sha512-bKMi6bBkEeN4a3qTxCykhrAvE0ESHhKO38Qh1bN/8QSyvKVAEyVAVls5W9IN5GKRHvXgEn9aw+DSzRnPpy9nyw==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.2.3.tgz", + "integrity": "sha512-A7WWl1/VsZw6utFFPBib1wSbAB5OeBgAgQmVpVe9wW8u9UZa6CLc7b3InWtRRyBXTo9Sa5GNZDFfwlXhy3iW3w==", "dev": true, "dependencies": { "ajv": "8.12.0", @@ -320,31 +286,13 @@ } } }, - "node_modules/@angular-devkit/core/node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true - }, - "node_modules/@angular-devkit/core/node_modules/picomatch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", - "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@angular-devkit/schematics": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.2.2.tgz", - "integrity": "sha512-t6dBhHvto9BEIo+Kew0+YyIS3TV1SEd4MActUk+zF4NNQyJ8wRUHL+8glUKB6ZWPyCTYSinJ+QKn/3yytELTHg==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.2.3.tgz", + "integrity": "sha512-JZCzHHheotv+iJ4p6qLc3pEi2M8NO12Slo6uiCg2T9B01glAcJB7DA1nwqjwD1cElf24Pt0C+HI0r+Lng48IsQ==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.2.2", + "@angular-devkit/core": "17.2.3", "jsonc-parser": "3.2.1", "magic-string": "0.30.7", "ora": "5.4.1", @@ -356,12 +304,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true - }, "node_modules/@angular-eslint/builder": { "version": "17.2.1", "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-17.2.1.tgz", @@ -414,606 +356,92 @@ "typescript": "*" } }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/scope-manager": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", - "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/type-utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.0.tgz", - "integrity": "sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==", + "node_modules/@angular-eslint/schematics": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-17.2.1.tgz", + "integrity": "sha512-7ldtIePI4ZTp/TBpeOZkzfv30HSAn//4TgtFuqvojudI8n8batV5FqQ0VNm1e0zitl75t8Zwtr0KYT4I6vh59g==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.19.0", - "@typescript-eslint/utils": "6.19.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@angular-eslint/eslint-plugin": "17.2.1", + "@angular-eslint/eslint-plugin-template": "17.2.1", + "@nx/devkit": "17.2.8", + "ignore": "5.3.0", + "nx": "17.2.8", + "strip-json-comments": "3.1.1", + "tmp": "0.2.1" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/types": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", - "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@angular/cli": ">= 17.0.0 < 18.0.0" } }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", - "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", + "node_modules/@angular-eslint/template-parser": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-17.2.1.tgz", + "integrity": "sha512-WPQYFvRju0tCDXQ/pwrzC911pE07JvpeDgcN2elhzV6lxDHJEZpA5O9pnW9qgNA6J6XM9Q7dBkJ22ztAzC4WFw==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@angular-eslint/bundled-angular-compiler": "17.2.1", + "eslint-scope": "^8.0.0" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^7.20.0 || ^8.0.0", + "typescript": "*" } }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", + "node_modules/@angular-eslint/utils": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-17.2.1.tgz", + "integrity": "sha512-qQYTBXy90dWM7fhhpa5i9lTtqqhJisvRa+naCrQx9kBgR458JScLdkVIdcZ9D/rPiDCmKiVUfgcDISnjUeqTqg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.19.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "@angular-eslint/bundled-angular-compiler": "17.2.1", + "@typescript-eslint/utils": "6.19.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" + "peerDependencies": { + "eslint": "^7.20.0 || ^8.0.0", + "typescript": "*" } }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, + "node_modules/@angular/animations": { + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.2.4.tgz", + "integrity": "sha512-eTjD8XeioL1Xj+W6iQayOh2JBCfjkg+MG3wzyEW0jhetE/N+wm2xbI1aub2pYplKsu96hOih3lfowYt7qIKGfw==", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" + "tslib": "^2.3.0" }, "engines": { - "node": ">=10" + "node": "^18.13.0 || >=20.9.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@angular/core": "17.2.4" } }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, + "node_modules/@angular/cdk": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.2.2.tgz", + "integrity": "sha512-no3FownDI+05SvCGOxduramTJw+V5p/rKebz4msZbsAXXLnOScZPN2rDgMKShl2dQokc6gjsKXsy8fAYpx7NSQ==", "dependencies": { - "brace-expansion": "^2.0.1" + "tslib": "^2.3.0" }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@angular-eslint/eslint-plugin-template/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", - "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", - "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", - "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@angular-eslint/eslint-plugin/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@angular-eslint/schematics": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-17.2.1.tgz", - "integrity": "sha512-7ldtIePI4ZTp/TBpeOZkzfv30HSAn//4TgtFuqvojudI8n8batV5FqQ0VNm1e0zitl75t8Zwtr0KYT4I6vh59g==", - "dev": true, - "dependencies": { - "@angular-eslint/eslint-plugin": "17.2.1", - "@angular-eslint/eslint-plugin-template": "17.2.1", - "@nx/devkit": "17.2.8", - "ignore": "5.3.0", - "nx": "17.2.8", - "strip-json-comments": "3.1.1", - "tmp": "0.2.1" - }, - "peerDependencies": { - "@angular/cli": ">= 17.0.0 < 18.0.0" - } - }, - "node_modules/@angular-eslint/template-parser": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-17.2.1.tgz", - "integrity": "sha512-WPQYFvRju0tCDXQ/pwrzC911pE07JvpeDgcN2elhzV6lxDHJEZpA5O9pnW9qgNA6J6XM9Q7dBkJ22ztAzC4WFw==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.2.1", - "eslint-scope": "^8.0.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/template-parser/node_modules/eslint-scope": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.0.tgz", - "integrity": "sha512-zj3Byw6jX4TcFCJmxOzLt6iol5FAr9xQyZZSQjEzW2UiCJXLwXdRIKCYVFftnpZckaC9Ps9xlC7jB8tSeWWOaw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@angular-eslint/utils": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-17.2.1.tgz", - "integrity": "sha512-qQYTBXy90dWM7fhhpa5i9lTtqqhJisvRa+naCrQx9kBgR458JScLdkVIdcZ9D/rPiDCmKiVUfgcDISnjUeqTqg==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.2.1", - "@typescript-eslint/utils": "6.19.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", - "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", - "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", - "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@angular-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.19.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@angular-eslint/utils/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@angular-eslint/utils/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@angular-eslint/utils/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@angular-eslint/utils/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@angular/animations": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.2.3.tgz", - "integrity": "sha512-eQcN6hC/dXISEYC/TjRuQJgfdZieBROBlXrS+BxRbsy9T4/QeKxChC3yiNxTmdxl5mvjLKvQTXHR8X0AWc07/Q==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0" - }, - "peerDependencies": { - "@angular/core": "17.2.3" - } - }, - "node_modules/@angular/cdk": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.2.1.tgz", - "integrity": "sha512-9cWV9MyWnpImns/WQApgoQBKblXA9Zx2CpCkDNipRgx9RyvGrvCLjpEfwQI4HjpPAQDI1trsbeJKihzgz4tFgw==", - "dependencies": { - "tslib": "^2.3.0" - }, - "optionalDependencies": { - "parse5": "^7.1.2" + "optionalDependencies": { + "parse5": "^7.1.2" }, "peerDependencies": { "@angular/common": "^17.0.0 || ^18.0.0", - "@angular/core": "^17.0.0 || ^18.0.0", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/cdk/node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "optional": true, - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "@angular/core": "^17.0.0 || ^18.0.0", + "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/cli": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.2.2.tgz", - "integrity": "sha512-cGGOnOTjU1bHBAU+5LMR1vfjUSmIY204pUcRAHu6xq1Qp8jm0Wf1lYOG1KrzpDezKa8d0WZe6FIVlxsCZRRYSw==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.2.3.tgz", + "integrity": "sha512-GIF9NF4t8PiHS4wt6baw1hECfmMOmNHvDAuT12/xoAueOairxIQ+AX13WaEHMJriWujm31TjqbwXmhPxMSEQpw==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1702.2", - "@angular-devkit/core": "17.2.2", - "@angular-devkit/schematics": "17.2.2", - "@schematics/angular": "17.2.2", + "@angular-devkit/architect": "0.1702.3", + "@angular-devkit/core": "17.2.3", + "@angular-devkit/schematics": "17.2.3", + "@schematics/angular": "17.2.3", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", "ini": "4.1.1", @@ -1038,49 +466,10 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular/cli/node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true - }, - "node_modules/@angular/cli/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@angular/cli/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@angular/cli/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@angular/common": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.2.3.tgz", - "integrity": "sha512-XR3rWS4W7/+RknyJMUUo9E81mSeyUznpclqTZ+Hy7+i4Naeso0qcRaIyr6JJmB5UGvlnfT1MlH9Fj78Dc80NEw==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.2.4.tgz", + "integrity": "sha512-ymzDHZPQWpBKVQ7lPZucU+vBSb70Re6y5TKzkOX7oYE8Z1+tiNGLvfmzGsO2/N0lvwyZWXjkdXYEDON2hIlZ1Q==", "dependencies": { "tslib": "^2.3.0" }, @@ -1088,14 +477,14 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.2.3", + "@angular/core": "17.2.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.2.3.tgz", - "integrity": "sha512-U2okLZ+4ipD5zTv32pMp+RsrM3kkP0XneSsIMPRpYZZfKgfnGLIwkRx6FoVoBwByugng6lBG/PiIe8DhRU/HFg==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.2.4.tgz", + "integrity": "sha512-McSsBcoHhMkaQpHM5/wTosAKTzJY5uE6ji3z+ec5GrIJhV7jrVfa67+RUoUzHe+rlD/7oQbX1L/OaHKDP8+/mA==", "dependencies": { "tslib": "^2.3.0" }, @@ -1103,7 +492,7 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.2.3" + "@angular/core": "17.2.4" }, "peerDependenciesMeta": { "@angular/core": { @@ -1112,9 +501,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.2.3.tgz", - "integrity": "sha512-mATybangypneXwO270VQeIw3N0avzc2Lpvdb8nm9WZYj23AcTUzpUUKOn63HtJdwMT5J2GjkyZFSRXisiPmpkA==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.2.4.tgz", + "integrity": "sha512-VGQx1YoYuifQZNj2/nGMEyYVYvXSWrt1ZXK43dgxPDH3jCWNncOBUYtmyCmYvxKvDz0aDO3KL8cro8c4+N0pPw==", "dev": true, "dependencies": { "@babel/core": "7.23.9", @@ -1135,14 +524,14 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/compiler": "17.2.3", + "@angular/compiler": "17.2.4", "typescript": ">=5.2 <5.4" } }, "node_modules/@angular/core": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.2.3.tgz", - "integrity": "sha512-DU+RdUB4E4I489R2P2hOrgkCDJNXlVaTzYixpgeDnuldCIYM0MatEzjor9DYNL3EDCayHF+M4HlVOcn6T/IVPQ==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.2.4.tgz", + "integrity": "sha512-5Bko+vk7H1Ce57MHuRcpZtq2Srq5euufSvwg0piPozp0yYmCqNoYN7c128kgi6PbiPQeAnKRzRbEuYd1YCU4Tw==", "dependencies": { "tslib": "^2.3.0" }, @@ -1155,9 +544,9 @@ } }, "node_modules/@angular/forms": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.2.3.tgz", - "integrity": "sha512-v+/6pimht808F5XpmVTNV4/109s+A7m3nadQP97qvIDsrtwrPPZR7cST+DRioG2C41VwtjXM0HVbIon/3ydo6A==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.2.4.tgz", + "integrity": "sha512-flubCxK6Rc1YmAu23+o+NwqaIWbJ4MIYij05b1GlpRKB5GRX6M0fOl7uRHZmA6dC4xZGt/MUklRqb71T7dJ5JQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -1165,16 +554,16 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.2.3", - "@angular/core": "17.2.3", - "@angular/platform-browser": "17.2.3", + "@angular/common": "17.2.4", + "@angular/core": "17.2.4", + "@angular/platform-browser": "17.2.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/material": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-17.2.1.tgz", - "integrity": "sha512-NLQJkX4XiwIm32dGdNseoc+ARn6JvuB2xMY5XfWTtjJBbQaPk5sIvjH4wsAEeYqDKtZbRCjxGwRz0K1djyaVqQ==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-17.2.2.tgz", + "integrity": "sha512-ToUp8gARTvdze9L7jhEuKqdos221jUCMRD6qzhl07XZRlxVbf/5VXUq2Nn7ei9uN11Ii1UY5pC0GS2XtlyHp4A==", "dependencies": { "@material/animation": "15.0.0-canary.7f224ddd4.0", "@material/auto-init": "15.0.0-canary.7f224ddd4.0", @@ -1227,7 +616,7 @@ }, "peerDependencies": { "@angular/animations": "^17.0.0 || ^18.0.0", - "@angular/cdk": "17.2.1", + "@angular/cdk": "17.2.2", "@angular/common": "^17.0.0 || ^18.0.0", "@angular/core": "^17.0.0 || ^18.0.0", "@angular/forms": "^17.0.0 || ^18.0.0", @@ -1236,9 +625,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.2.3.tgz", - "integrity": "sha512-bFi+H8avyCjwSBy+zpOKmqx852MRH8fkuZa4XgwKCPJRay8BfSCjHdtIo3eokUNPMu9JsyXM7HYKIfzLu5y6LA==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.2.4.tgz", + "integrity": "sha512-A1jkx4ApIx76VDxm8UZLKEq+gwpKZb4qjzCTBDfjOpXB0MJQ5IaYdCrV0E/vPCKZhIfjbEHK+9H1vHRYDCcXtA==", "dependencies": { "tslib": "^2.3.0" }, @@ -1246,9 +635,9 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/animations": "17.2.3", - "@angular/common": "17.2.3", - "@angular/core": "17.2.3" + "@angular/animations": "17.2.4", + "@angular/common": "17.2.4", + "@angular/core": "17.2.4" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1257,9 +646,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.2.3.tgz", - "integrity": "sha512-K8CsHbmG2nvV1jrNN9PYxyA0zJNoIWp+qf2udvPhG8rJ+Pyw61qmptrarpQUUkr8ONOtjwtOsnKa9/w+15nExw==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.2.4.tgz", + "integrity": "sha512-tNS6WexBbdks4uiB0JfPjUG2/rJ/5wuWr9C11CIgsMo+Onbw49imwDQQTxsx1A3misVb72mUufRza9DcxfSBxg==", "dependencies": { "tslib": "^2.3.0" }, @@ -1267,16 +656,16 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.2.3", - "@angular/compiler": "17.2.3", - "@angular/core": "17.2.3", - "@angular/platform-browser": "17.2.3" + "@angular/common": "17.2.4", + "@angular/compiler": "17.2.4", + "@angular/core": "17.2.4", + "@angular/platform-browser": "17.2.4" } }, "node_modules/@angular/router": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.2.3.tgz", - "integrity": "sha512-8UPjMzI98xZ6cDNm0MzHd9hFq6aOQJGmgxKDUPIG2h74glRwwbiewpo5hPo2EGIF8BLvQmmAm9ytr5zesHu0cg==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.2.4.tgz", + "integrity": "sha512-HnEq6OtyXVJx24Vps0N2GsdvynQ8Mv6twjGmhBlo3x/19ay0WEHdHdsayOSKFvxXg9LCLPnSDYlmpk074IsgqA==", "dependencies": { "tslib": "^2.3.0" }, @@ -1284,9 +673,9 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.2.3", - "@angular/core": "17.2.3", - "@angular/platform-browser": "17.2.3", + "@angular/common": "17.2.4", + "@angular/core": "17.2.4", + "@angular/platform-browser": "17.2.4", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -1480,9 +869,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.0.tgz", + "integrity": "sha512-efwOM90nCG6YeT8o3PCyBVSxRfmILxCNL+TNI8CGQl7a62M0Wd9VkV+XHwIlkOz1r4b+lxu6gBjdWiOMdUCrCQ==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -3034,9 +2423,9 @@ } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.3.2.tgz", - "integrity": "sha512-sLYGdAdEY2x7TSw9FtmdaTrh2wFtRJO5VMbBrA8tEqEod7GEggFmxTSK9XqExib3yMuYNcvcTdCZIP6ukdjAIA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.6.0.tgz", + "integrity": "sha512-YfEHq0eRH98ffb5/EsrrDspVWAuph6gDggAE74ZtjecsmyyWpW768hOyiONa8zwWGbIWYfa2Xp4tRTrpQQ00CQ==", "dev": true, "funding": [ { @@ -3052,13 +2441,13 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.2.1" + "@csstools/css-tokenizer": "^2.2.3" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.1.tgz", - "integrity": "sha512-Zmsf2f/CaEPWEVgw29odOj+WEVoiJy9s9NOv5GgNY9mZ1CZ7394By6wONrONrTsnNDv6F9hR02nvFihrGVGHBg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.3.tgz", + "integrity": "sha512-pp//EvZ9dUmGuGtG1p+n17gTHEOqu9jO+FiCUjNN3BDmyhdA2Jq9QsVeR7K8/2QCK17HSsioPlTW9ZkzoWb3Lg==", "dev": true, "funding": [ { @@ -3075,9 +2464,9 @@ } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.5.tgz", - "integrity": "sha512-IxVBdYzR8pYe89JiyXQuYk4aVVoCPhMJkz6ElRwlVysjwURTsTk/bmY/z4FfeRE+CRBMlykPwXEVUg8lThv7AQ==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.8.tgz", + "integrity": "sha512-DiD3vG5ciNzeuTEoh74S+JMjQDs50R3zlxHnBnfd04YYfA/kh2KiBCGhzqLxlJcNq+7yNQ3stuZZYLX6wK/U2g==", "dev": true, "funding": [ { @@ -3093,14 +2482,14 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.3.2", - "@csstools/css-tokenizer": "^2.2.1" + "@csstools/css-parser-algorithms": "^2.6.0", + "@csstools/css-tokenizer": "^2.2.3" } }, "node_modules/@csstools/selector-specificity": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.0.0.tgz", - "integrity": "sha512-hBI9tfBtuPIi885ZsZ32IMEU/5nlZH/KOVYJCOh7gyMxaVLGmLedYqFN6Ui1LXkI8JlC8IsuC0rF0btcRZKd5g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.0.2.tgz", + "integrity": "sha512-RpHaZ1h9LE7aALeQXmXrJkRG84ZxIsctEN2biEUmFyKpzFM3zZ35eUMcIzZFsw/2olQE6v69+esEqU2f1MKycg==", "dev": true, "funding": [ { @@ -3521,9 +2910,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", - "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", @@ -3565,10 +2954,20 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -3598,6 +2997,18 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3611,9 +3022,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz", - "integrity": "sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3629,19 +3040,41 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -3656,9 +3089,9 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, "node_modules/@isaacs/cliui": { @@ -3795,32 +3228,32 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -3843,9 +3276,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3859,12 +3292,12 @@ "dev": true }, "node_modules/@ljharb/through": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.12.tgz", - "integrity": "sha512-ajo/heTlG3QgC8EGP6APIejksVAYt4ayz4tqoP3MolFELzcH1x1fzwEYRJTPO0IELutZ5HQ0c26/GqAYy79u3g==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.13.tgz", + "integrity": "sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.5" + "call-bind": "^1.0.7" }, "engines": { "node": ">= 0.4" @@ -4622,22 +4055,6 @@ "tslib": "^2.1.0" } }, - "node_modules/@ngtools/webpack": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.2.2.tgz", - "integrity": "sha512-HgvClGO6WVq4VA5d0ZvlDG5hrj8lQzRH99Gt87URm7G8E5XkatysdOsMqUQsJz+OwFWhP4PvTRWVblpBDiDl/A==", - "dev": true, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "@angular/compiler-cli": "^17.0.0", - "typescript": ">=5.2 <5.4", - "webpack": "^5.54.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4805,15 +4222,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/@npmcli/package-json/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@npmcli/package-json/node_modules/glob": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", @@ -4836,30 +4244,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@npmcli/package-json/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@npmcli/promise-spawn": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz", @@ -5180,9 +4564,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", - "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.1.tgz", + "integrity": "sha512-iU2Sya8hNn1LhsYyf0N+L4Gf9Qc+9eBTJJJsaOGUp+7x4n2M9dxTt8UvhJl3oeftSjblSlpCfvjA/IfP3g5VjQ==", "cpu": [ "arm" ], @@ -5193,9 +4577,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", - "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.1.tgz", + "integrity": "sha512-wlzcWiH2Ir7rdMELxFE5vuM7D6TsOcJ2Yw0c3vaBR3VOsJFVTx9xvwnAvhgU5Ii8Gd6+I11qNHwndDscIm0HXg==", "cpu": [ "arm64" ], @@ -5206,9 +4590,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", - "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.1.tgz", + "integrity": "sha512-YRXa1+aZIFN5BaImK+84B3uNK8C6+ynKLPgvn29X9s0LTVCByp54TB7tdSMHDR7GTV39bz1lOmlLDuedgTwwHg==", "cpu": [ "arm64" ], @@ -5219,9 +4603,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", - "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.1.tgz", + "integrity": "sha512-opjWJ4MevxeA8FhlngQWPBOvVWYNPFkq6/25rGgG+KOy0r8clYwL1CFd+PGwRqqMFVQ4/Qd3sQu5t7ucP7C/Uw==", "cpu": [ "x64" ], @@ -5232,9 +4616,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", - "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.1.tgz", + "integrity": "sha512-uBkwaI+gBUlIe+EfbNnY5xNyXuhZbDSx2nzzW8tRMjUmpScd6lCQYKY2V9BATHtv5Ef2OBq6SChEP8h+/cxifQ==", "cpu": [ "arm" ], @@ -5245,9 +4629,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", - "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.1.tgz", + "integrity": "sha512-0bK9aG1kIg0Su7OcFTlexkVeNZ5IzEsnz1ept87a0TUgZ6HplSgkJAnFpEVRW7GRcikT4GlPV0pbtVedOaXHQQ==", "cpu": [ "arm64" ], @@ -5258,9 +4642,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", - "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.1.tgz", + "integrity": "sha512-qB6AFRXuP8bdkBI4D7UPUbE7OQf7u5OL+R94JE42Z2Qjmyj74FtDdLGeriRyBDhm4rQSvqAGCGC01b8Fu2LthQ==", "cpu": [ "arm64" ], @@ -5271,9 +4655,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", - "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.1.tgz", + "integrity": "sha512-sHig3LaGlpNgDj5o8uPEoGs98RII8HpNIqFtAI8/pYABO8i0nb1QzT0JDoXF/pxzqO+FkxvwkHZo9k0NJYDedg==", "cpu": [ "riscv64" ], @@ -5284,9 +4668,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", - "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.1.tgz", + "integrity": "sha512-nD3YcUv6jBJbBNFvSbp0IV66+ba/1teuBcu+fBBPZ33sidxitc6ErhON3JNavaH8HlswhWMC3s5rgZpM4MtPqQ==", "cpu": [ "x64" ], @@ -5297,9 +4681,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", - "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.1.tgz", + "integrity": "sha512-7/XVZqgBby2qp/cO0TQ8uJK+9xnSdJ9ct6gSDdEr4MfABrjTyrW6Bau7HQ73a2a5tPB7hno49A0y1jhWGDN9OQ==", "cpu": [ "x64" ], @@ -5310,9 +4694,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", - "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.1.tgz", + "integrity": "sha512-CYc64bnICG42UPL7TrhIwsJW4QcKkIt9gGlj21gq3VV0LL6XNb1yAdHVp1pIi9gkts9gGcT3OfUYHjGP7ETAiw==", "cpu": [ "arm64" ], @@ -5323,9 +4707,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", - "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.1.tgz", + "integrity": "sha512-LN+vnlZ9g0qlHGlS920GR4zFCqAwbv2lULrR29yGaWP9u7wF5L7GqWu9Ah6/kFZPXPUkpdZwd//TNR+9XC9hvA==", "cpu": [ "ia32" ], @@ -5336,9 +4720,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", - "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.1.tgz", + "integrity": "sha512-n+vkrSyphvmU0qkQ6QBNXCGr2mKjhP08mPRM/Xp5Ck2FV4NrHU+y6axzDeixUrCBHVUS51TZhjqrKBBsHLKb2Q==", "cpu": [ "x64" ], @@ -5349,13 +4733,13 @@ ] }, "node_modules/@schematics/angular": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.2.2.tgz", - "integrity": "sha512-Q3VAQ/S4gj8D1JPWgWG4enDdDZUu8mUXWVRG1rOi4sHgOF5zgPieQFp3LXqMUgOncmzbXrctkbO6NKc4N2FAag==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.2.3.tgz", + "integrity": "sha512-rXsYmWC1a8uvGTC6RwICwg1GLLQlTw8jOSqHf6T2AFMzP4p1FV3/GFSGyPIMl9yPwn6JqbmfQy3Bvj0stQNM0Q==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.2.2", - "@angular-devkit/schematics": "17.2.2", + "@angular-devkit/core": "17.2.3", + "@angular-devkit/schematics": "17.2.3", "jsonc-parser": "3.2.1" }, "engines": { @@ -5364,12 +4748,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@schematics/angular/node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true - }, "node_modules/@sigstore/bundle": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.2.0.tgz", @@ -5476,30 +4854,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -5545,18 +4899,18 @@ "dev": true }, "node_modules/@types/cors": { - "version": "2.8.15", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.15.tgz", - "integrity": "sha512-n91JxbNLD8eQIuXDIChAN1tCKNWCEgpceU9b7ZMbFA+P+Q4yIeh80jizFLEvolRPc1ES0VdwFlGv+kJTSirogw==", + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", "dev": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/eslint": { - "version": "8.44.6", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.6.tgz", - "integrity": "sha512-P6bY56TVmX8y9J87jHNgQh43h6VVU+6H7oN7hgvivV81K2XY8qJZ5vqPy/HdUoVIelii2kChYVzQanlswPWVFw==", + "version": "8.56.5", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.5.tgz", + "integrity": "sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==", "dev": true, "dependencies": { "@types/estree": "*", @@ -5564,9 +4918,9 @@ } }, "node_modules/@types/eslint-scope": { - "version": "3.7.6", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.6.tgz", - "integrity": "sha512-zfM4ipmxVKWdxtDaJ3MP3pBurDXOCoyjvlpE3u6Qzrmw4BPbfm4/ambIeTk/r/J0iq/+2/xp0Fmt+gFvXJY2PQ==", + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "dependencies": { "@types/eslint": "*", @@ -5625,9 +4979,9 @@ "dev": true }, "node_modules/@types/json-schema": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", - "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, "node_modules/@types/mime": { @@ -5637,15 +4991,15 @@ "dev": true }, "node_modules/@types/minimist": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.4.tgz", - "integrity": "sha512-Kfe/D3hxHTusnPNRbycJE1N77WHDsdS4AjUYIzlDzhDrS47NrwuL3YW4VITxwR7KCVpzwgy4Rbj829KSSQmwXQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, "node_modules/@types/node": { - "version": "20.8.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", - "integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==", + "version": "20.11.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", + "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -5661,9 +5015,9 @@ } }, "node_modules/@types/normalize-package-data": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.3.tgz", - "integrity": "sha512-ehPtgRgaULsFG8x0NeYJvmyH1hmlfsNLujHe9dQEia/7MAJYdzMSi19JtchUHjmBA6XC/75dK55mzZH+RyieSg==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, "node_modules/@types/qs": { @@ -5685,9 +5039,9 @@ "dev": true }, "node_modules/@types/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, "node_modules/@types/send": { @@ -5730,9 +5084,9 @@ } }, "node_modules/@types/uuid": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz", - "integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "dev": true }, "node_modules/@types/ws": { @@ -5745,16 +5099,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz", - "integrity": "sha512-XOpZ3IyJUIV1b15M7HVOpgQxPPF7lGXgsfcEIu3yDxFPaf/xZKt7s9QO/pbk7vpWQyVulpJbu4E5LwpZiQo4kA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.12.0", - "@typescript-eslint/type-utils": "6.12.0", - "@typescript-eslint/utils": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -5779,16 +5133,68 @@ } } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.12.0.tgz", - "integrity": "sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.12.0", - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/typescript-estree": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { @@ -5807,15 +5213,55 @@ } } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.12.0.tgz", - "integrity": "sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.0.tgz", + "integrity": "sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.19.0", + "@typescript-eslint/utils": "6.19.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", + "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0" - }, "engines": { "node": "^16.0.0 || >=18.0.0" }, @@ -5824,15 +5270,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.12.0.tgz", - "integrity": "sha512-WWmRXxhm1X8Wlquj+MhsAG4dU/Blvf1xDgGaYCzfvStP2NwPQh6KBvCDbiOEvaE0filhranjIlK/2fSTVwtBng==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", + "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.12.0", - "@typescript-eslint/utils": "6.12.0", + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0", "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", "ts-api-utils": "^1.0.1" }, "engines": { @@ -5842,19 +5292,33 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, "peerDependenciesMeta": { "typescript": { "optional": true } } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", + "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/types": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.12.0.tgz", - "integrity": "sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -5865,16 +5329,17 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.12.0.tgz", - "integrity": "sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", + "minimatch": "9.0.3", "semver": "^7.5.4", "ts-api-utils": "^1.0.1" }, @@ -5891,48 +5356,75 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/@typescript-eslint/utils": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", + "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", "dev": true, "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.19.0", + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/typescript-estree": "6.19.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=10" + "node": "^16.0.0 || >=18.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", + "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0" + }, "engines": { - "node": ">=8" + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/utils": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.12.0.tgz", - "integrity": "sha512-LywPm8h3tGEbgfyjYnu3dauZ0U7R60m+miXgKcZS8c7QALO9uWJdvNoP+duKTk2XMWc7/Q3d/QiCuLN9X6SWyQ==", + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", + "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", + "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.12.0", - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/typescript-estree": "6.12.0", - "semver": "^7.5.4" + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -5941,17 +5433,36 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", + "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.19.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.12.0.tgz", - "integrity": "sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.12.0", + "@typescript-eslint/types": "6.21.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -6198,9 +5709,9 @@ } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -6394,6 +5905,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -6548,13 +6071,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", - "integrity": "sha512-OtIuQfafSzpo/LhnJaykc0R/MMnuLSSVjVYy9mHArIZ9qTCSZ6TpWCuEKZYVoN//t8HqBNScHrOtCrIK5IaGLg==", + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.9.tgz", + "integrity": "sha512-BXIWIaO3MewbXWdJdIGDWZurv5OGJlFNo7oy20DpB3kWDVJLcY2NRypRsRUbRe5KMqSNLuOGnWTFQQtY5MAsRw==", "dev": true, "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.5.0", + "@babel/helper-define-polyfill-provider": "^0.6.0", "semver": "^6.3.1" }, "peerDependencies": { @@ -6583,6 +6106,22 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", + "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/babel-plugin-polyfill-regenerator": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", @@ -6595,6 +6134,22 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-polyfill-regenerator/node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", + "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6744,13 +6299,12 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -6867,15 +6421,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/cacache/node_modules/glob": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", @@ -6907,30 +6452,20 @@ "node": "14 || >=16.14" } }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6997,9 +6532,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001591", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001591.tgz", - "integrity": "sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==", + "version": "1.0.30001597", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz", + "integrity": "sha512-7LjJvmQU6Sj7bL0j5b5WY/3n7utXUJvAe1lxhsHDbLmwX9mdL86Yjtr+5SRCyf8qME4M7pU2hswj0FpyBVCv9w==", "dev": true, "funding": [ { @@ -7037,16 +6572,10 @@ "dev": true }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -7059,6 +6588,9 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -7103,9 +6635,9 @@ } }, "node_modules/cli-spinners": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz", - "integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", + "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", "dev": true, "engines": { "node": ">=6" @@ -7120,21 +6652,71 @@ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "engines": { - "node": ">= 12" + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/clone": { @@ -7160,6 +6742,18 @@ "node": ">=6" } }, + "node_modules/clone-deep/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -7406,6 +7000,37 @@ "node": ">=10.13.0" } }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dev": true, + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/core-js-compat": { "version": "3.36.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz", @@ -7439,15 +7064,15 @@ } }, "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "dependencies": { + "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "parse-json": "^5.2.0" }, "engines": { "node": ">=14" @@ -7581,21 +7206,6 @@ "node": ">= 8" } }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/css-functions-list": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.1.tgz", @@ -7802,17 +7412,20 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-lazy-prop": { @@ -8040,9 +7653,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.689", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.689.tgz", - "integrity": "sha512-GatzRKnGPS1go29ep25reM94xxd1Wj8ritU0yRhCJ/tr1Bg8gKnm6R9O/yPOhGQBoLMZ9ezfrpghNaTw97C/PQ==", + "version": "1.4.699", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.699.tgz", + "integrity": "sha512-I7q3BbQi6e4tJJN5CRcyvxhK0iJb34TV8eJQcgh+fR2fQ8miMgZcEInckCo1U9exDHbfz7DLDnFn8oqH/VcRKw==", "dev": true }, "node_modules/emoji-regex": { @@ -8102,9 +7715,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.3.tgz", - "integrity": "sha512-IML/R4eG/pUS5w7OfcDE0jKrljWS9nwnEfsxWCIJF5eO6AHo6+Hlv+lQbdlAYsiJPHzUthLm1RUjnBzWOs45cw==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", "dev": true, "dependencies": { "@types/cookie": "^0.4.1", @@ -8123,18 +7736,18 @@ } }, "node_modules/engine.io-parser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", - "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", "dev": true, "engines": { "node": ">=10.0.0" } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", + "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -8211,10 +7824,31 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", "dev": true }, "node_modules/esbuild": { @@ -8269,9 +7903,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, "engines": { "node": ">=6" @@ -8293,16 +7927,16 @@ } }, "node_modules/eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.54.0.tgz", - "integrity": "sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.54.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -8348,9 +7982,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "bin": { "eslint-config-prettier": "bin/cli.js" @@ -8360,25 +7994,19 @@ } }, "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.0.tgz", + "integrity": "sha512-zj3Byw6jX4TcFCJmxOzLt6iol5FAr9xQyZZSQjEzW2UiCJXLwXdRIKCYVFftnpZckaC9Ps9xlC7jB8tSeWWOaw==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-scope/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { @@ -8430,6 +8058,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8521,9 +8159,9 @@ } }, "node_modules/eslint/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -8577,6 +8215,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8631,18 +8281,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -8937,9 +8575,9 @@ } }, "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -8993,15 +8631,6 @@ "minimatch": "^5.0.1" } }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/filelist/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -9110,9 +8739,9 @@ } }, "node_modules/flat-cache": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", - "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "dependencies": { "flatted": "^3.2.9", @@ -9120,19 +8749,19 @@ "rimraf": "^3.0.2" }, "engines": { - "node": ">=12.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "dev": true, "funding": [ { @@ -9229,17 +8858,17 @@ "dev": true }, "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "dev": true, "dependencies": { "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6 <7 || >=8" + "node": ">=14.14" } }, "node_modules/fs-minipass": { @@ -9308,16 +8937,20 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9381,6 +9014,28 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -9413,6 +9068,18 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -9423,19 +9090,20 @@ } }, "node_modules/globby": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", - "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "dependencies": { + "array-union": "^2.1.0", "dir-glob": "^3.0.1", - "fast-glob": "^3.3.0", - "ignore": "^5.2.4", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", "merge2": "^1.4.1", - "slash": "^4.0.0" + "slash": "^3.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9496,21 +9164,21 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "dev": true, "engines": { "node": ">= 0.4" @@ -9532,9 +9200,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -9607,9 +9275,9 @@ } }, "node_modules/html-entities": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", - "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", "dev": true, "funding": [ { @@ -9854,30 +9522,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/ignore-walk/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/image-size": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", @@ -10000,21 +9644,6 @@ "node": ">=18" } }, - "node_modules/inquirer/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/inquirer/node_modules/chalk": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", @@ -10027,38 +9656,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/inquirer/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/inquirer/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -10208,13 +9805,10 @@ } }, "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, "engines": { "node": ">=0.10.0" } @@ -10295,9 +9889,9 @@ } }, "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "engines": { "node": ">=8" @@ -10387,9 +9981,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, "dependencies": { "html-escaper": "^2.0.0", @@ -10450,6 +10044,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jake/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/jake/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -10493,6 +10097,18 @@ "node": ">=8" } }, + "node_modules/jake/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/jake/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10511,18 +10127,6 @@ "integrity": "sha512-O236+gd0ZXS8YAjFx8xKaJ94/erqUliEkJTDedyE7iHvv4ZVqi+q+8acJxu05/WJDKm512EUNn809In37nWlAQ==", "dev": true }, - "node_modules/jasmine-marbles": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/jasmine-marbles/-/jasmine-marbles-0.9.2.tgz", - "integrity": "sha512-T7RjG4fRsdiGGzbQZ6Kj39qYt6O1/KIcR4FkUNsD3DUGkd/AzpwzN+xtk0DXlLWEz5BaVdK1SzMgQDVw879c4Q==", - "dev": true, - "dependencies": { - "lodash": "^4.17.20" - }, - "peerDependencies": { - "rxjs": "^7.0.0" - } - }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -10708,10 +10312,13 @@ "dev": true }, "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", + "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -10738,16 +10345,19 @@ } }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", "dev": true }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -10762,9 +10372,9 @@ ] }, "node_modules/karma": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", - "integrity": "sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.3.tgz", + "integrity": "sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==", "dev": true, "dependencies": { "@colors/colors": "1.5.0", @@ -10786,7 +10396,7 @@ "qjobs": "^1.2.0", "range-parser": "^1.2.1", "rimraf": "^3.0.2", - "socket.io": "^4.4.1", + "socket.io": "^4.7.2", "source-map": "^0.6.1", "tmp": "^0.2.1", "ua-parser-js": "^0.7.30", @@ -10808,6 +10418,18 @@ "which": "^1.2.1" } }, + "node_modules/karma-chrome-launcher/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/karma-coverage": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", @@ -10825,6 +10447,28 @@ "node": ">=10.0.0" } }, + "node_modules/karma-coverage/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/karma-coverage/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/karma-jasmine": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", @@ -10860,6 +10504,31 @@ "source-map-support": "^0.5.5" } }, + "node_modules/karma/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/karma/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/karma/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -10871,6 +10540,36 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/karma/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/karma/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/karma/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/karma/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10880,6 +10579,23 @@ "node": ">=0.10.0" } }, + "node_modules/karma/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/karma/node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -11074,10 +10790,13 @@ } }, "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } }, "node_modules/loader-runner": { "version": "4.3.0", @@ -11477,6 +11196,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -11553,17 +11284,20 @@ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "dev": true - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -11859,13 +11593,12 @@ "dev": true }, "node_modules/needle": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.2.0.tgz", - "integrity": "sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", + "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", "dev": true, "optional": true, "dependencies": { - "debug": "^3.2.6", "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, @@ -11876,16 +11609,6 @@ "node": ">= 4.4.x" } }, - "node_modules/needle/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "optional": true, - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/needle/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -11981,15 +11704,6 @@ "node-gyp-build-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/node-gyp/node_modules/glob": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", @@ -12021,21 +11735,6 @@ "node": ">=16" } }, - "node_modules/node-gyp/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -12320,6 +12019,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/nx/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/nx/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -12336,18 +12045,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/nx/node_modules/cli-spinners": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", - "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nx/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -12366,20 +12063,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/nx/node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/nx/node_modules/glob": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", @@ -12418,26 +12101,11 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/nx/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/nx/node_modules/lines-and-columns": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", - "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } + "node_modules/nx/node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true }, "node_modules/nx/node_modules/lru-cache": { "version": "6.0.0", @@ -12490,15 +12158,6 @@ "node": ">=8" } }, - "node_modules/nx/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/nx/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -12845,6 +12504,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/parse-json/node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, "node_modules/parse-node-version": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", @@ -12854,6 +12525,18 @@ "node": ">= 0.10" } }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "devOptional": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5-html-rewriting-stream": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", @@ -12868,18 +12551,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5-html-rewriting-stream/node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parse5-sax-parser": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", @@ -12892,18 +12563,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5-sax-parser/node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -12993,12 +12652,12 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", "dev": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -13108,6 +12767,18 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/pkg-dir/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/postcss": { "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", @@ -13167,50 +12838,6 @@ } } }, - "node_modules/postcss-loader/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/postcss-loader/node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/postcss-loader/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", @@ -13325,9 +12952,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", - "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "version": "6.0.15", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", + "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -13353,9 +12980,9 @@ } }, "node_modules/prettier": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", - "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -13475,9 +13102,9 @@ "optional": true }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "engines": { "node": ">=6" @@ -13606,62 +13233,20 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-package-json/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/read-package-json/node_modules/glob": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", "dev": true, "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/read-package-json/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-package-json/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -13778,18 +13363,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg-up/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/read-pkg/node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -13873,6 +13446,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/redent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", @@ -13941,9 +13526,9 @@ } }, "node_modules/regex-parser": { - "version": "2.2.11", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", - "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", + "integrity": "sha512-TVILVSz2jY5D47F4mA4MppkBrafEaiUWJO/TcZHEIuI13AqoZMkK1WMA4Om1YkYbTx+9Ki1/tSUXbceyr9saRg==", "dev": true }, "node_modules/regexpu-core": { @@ -14106,9 +13691,9 @@ } }, "node_modules/rfdc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", + "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", "dev": true }, "node_modules/rimraf": { @@ -14127,9 +13712,9 @@ } }, "node_modules/rollup": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", - "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.1.tgz", + "integrity": "sha512-ggqQKvx/PsB0FaWXhIvVkSWh7a/PCLQAsMjBc+nA2M8Rv2/HG0X6zvixAB7KyZBRtifBUhy5k8voQX/mRnABPg==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -14142,22 +13727,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.12.0", - "@rollup/rollup-android-arm64": "4.12.0", - "@rollup/rollup-darwin-arm64": "4.12.0", - "@rollup/rollup-darwin-x64": "4.12.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", - "@rollup/rollup-linux-arm64-gnu": "4.12.0", - "@rollup/rollup-linux-arm64-musl": "4.12.0", - "@rollup/rollup-linux-riscv64-gnu": "4.12.0", - "@rollup/rollup-linux-x64-gnu": "4.12.0", - "@rollup/rollup-linux-x64-musl": "4.12.0", - "@rollup/rollup-win32-arm64-msvc": "4.12.0", - "@rollup/rollup-win32-ia32-msvc": "4.12.0", - "@rollup/rollup-win32-x64-msvc": "4.12.0", + "@rollup/rollup-android-arm-eabi": "4.12.1", + "@rollup/rollup-android-arm64": "4.12.1", + "@rollup/rollup-darwin-arm64": "4.12.1", + "@rollup/rollup-darwin-x64": "4.12.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.12.1", + "@rollup/rollup-linux-arm64-gnu": "4.12.1", + "@rollup/rollup-linux-arm64-musl": "4.12.1", + "@rollup/rollup-linux-riscv64-gnu": "4.12.1", + "@rollup/rollup-linux-x64-gnu": "4.12.1", + "@rollup/rollup-linux-x64-musl": "4.12.1", + "@rollup/rollup-win32-arm64-msvc": "4.12.1", + "@rollup/rollup-win32-ia32-msvc": "4.12.1", + "@rollup/rollup-win32-x64-msvc": "4.12.1", "fsevents": "~2.3.2" } }, + "node_modules/rsocket-adapter-rxjs": { + "version": "1.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/rsocket-adapter-rxjs/-/rsocket-adapter-rxjs-1.0.0-alpha.4.tgz", + "integrity": "sha512-rfrsui1/M1pBG8A47wsgpBwHU8xdy/xgdGFIC3ICQuBLBhJeENDt+dcZXu8/MaEyWQH3GXvIGBeargd0FZTF8A==", + "dependencies": { + "rsocket-core": "^1.0.0-alpha.3", + "rsocket-messaging": "^1.0.0-alpha.3", + "rxjs": "^7.4.0" + } + }, "node_modules/rsocket-composite-metadata": { "version": "1.0.0-alpha.3", "resolved": "https://registry.npmjs.org/rsocket-composite-metadata/-/rsocket-composite-metadata-1.0.0-alpha.3.tgz", @@ -14167,18 +13762,27 @@ } }, "node_modules/rsocket-core": { - "version": "1.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/rsocket-core/-/rsocket-core-1.0.0-alpha.3.tgz", - "integrity": "sha512-BzIe2w8dFJlUS5N9fGUNRkxL19kd64bxbXsT11wj7isLfKkPZeNXisB2p/LWvSjFzWStnpOiScZ0g3/8ROE0pw==" + "version": "1.0.0-alpha-rxjs-adapter-optional-metadata.0", + "resolved": "https://registry.npmjs.org/rsocket-core/-/rsocket-core-1.0.0-alpha-rxjs-adapter-optional-metadata.0.tgz", + "integrity": "sha512-FXieO2E8d4UC7mW7K01IRMtgwZ1BZk7l4ZEvgnD8xEKdt//9ks5IfiDZPvi1w9P4YFp/IrI7lL3dqhkOF0Ez/g==" }, - "node_modules/rsocket-websocket-client": { + "node_modules/rsocket-messaging": { "version": "1.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/rsocket-websocket-client/-/rsocket-websocket-client-1.0.0-alpha.3.tgz", - "integrity": "sha512-CwTwTNMGa8BKvrWde/kM3q8IHuzO8RCIfzuj25BsVe9y8eehDQHt4fXk0g1i/wpsxTm+RY6DxE6Vr5snozKVOg==", + "resolved": "https://registry.npmjs.org/rsocket-messaging/-/rsocket-messaging-1.0.0-alpha.3.tgz", + "integrity": "sha512-fr/BDCLI4DMyzVI5hHsABVpKlKxGX8rbfdgcXgY6VKuBlDIX0oqGApqLdSq0YfvNpgjlpuOXYj4vlhLku959Yw==", "dependencies": { + "rsocket-composite-metadata": "^1.0.0-alpha.3", "rsocket-core": "^1.0.0-alpha.3" } }, + "node_modules/rsocket-websocket-client": { + "version": "1.0.0-alpha-rxjs-adapter-optional-metadata.0", + "resolved": "https://registry.npmjs.org/rsocket-websocket-client/-/rsocket-websocket-client-1.0.0-alpha-rxjs-adapter-optional-metadata.0.tgz", + "integrity": "sha512-CFnXY/DdP/tyDQbvgiGWk+v/A7t1aj6gqKmka8A0Qesb+NBy+1ck/Mwq68JNNhvWlmESGqPmErmPDJ9d3XeMOA==", + "dependencies": { + "rsocket-core": "^1.0.0-alpha-rxjs-adapter-optional-metadata.0" + } + }, "node_modules/run-async": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", @@ -14353,9 +13957,9 @@ } }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -14452,9 +14056,9 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "dependencies": { "randombytes": "^2.1.0" @@ -14545,15 +14149,17 @@ } }, "node_modules/set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14608,14 +14214,18 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14645,15 +14255,12 @@ } }, "node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/slice-ansi": { @@ -14717,9 +14324,9 @@ } }, "node_modules/socket.io": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.2.tgz", - "integrity": "sha512-bvKVS29/I5fl2FGLNHuXlQaUH/BlzX1IN6S+NKLNZpBsPZIDH+90eQmCs2Railn4YUiww4SzUedJ6+uzwFnKLw==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.4.tgz", + "integrity": "sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==", "dev": true, "dependencies": { "accepts": "~1.3.4", @@ -14735,11 +14342,12 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", - "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", + "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", "dev": true, "dependencies": { + "debug": "~4.3.4", "ws": "~8.11.0" } }, @@ -14884,9 +14492,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -14900,9 +14508,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", "dev": true }, "node_modules/spdy": { @@ -14976,6 +14584,38 @@ "node": ">=8.0" } }, + "node_modules/streamroller/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/streamroller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/streamroller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -15178,14 +14818,14 @@ } }, "node_modules/stylelint-config-recommended-scss": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-13.0.0.tgz", - "integrity": "sha512-7AmMIsHTsuwUQm7I+DD5BGeIgCvqYZ4BpeYJJpb1cUXQwrJAKjA+GBotFZgUEGP8lAM+wmd91ovzOi8xfAyWEw==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-13.1.0.tgz", + "integrity": "sha512-8L5nDfd+YH6AOoBGKmhH8pLWF1dpfY816JtGMePcBqqSsLU+Ysawx44fQSlMOJ2xTfI9yTGpup5JU77c17w1Ww==", "dev": true, "dependencies": { - "postcss-scss": "^4.0.7", + "postcss-scss": "^4.0.9", "stylelint-config-recommended": "^13.0.0", - "stylelint-scss": "^5.1.0" + "stylelint-scss": "^5.3.0" }, "peerDependencies": { "postcss": "^8.3.3", @@ -15213,12 +14853,12 @@ } }, "node_modules/stylelint-config-standard-scss": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-11.0.0.tgz", - "integrity": "sha512-fGE79NBOLg09a9afqGH/guJulRULCaQWWv4cv1v2bMX92B+fGb0y56WqIguwvFcliPmmUXiAhKrrnXilIeXoHA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-11.1.0.tgz", + "integrity": "sha512-5gnBgeNTgRVdchMwiFQPuBOtj9QefYtfXiddrOMJA2pI22zxt6ddI2s+e5Oh7/6QYl7QLJujGnaUR5YyGq72ow==", "dev": true, "dependencies": { - "stylelint-config-recommended-scss": "^13.0.0", + "stylelint-config-recommended-scss": "^13.1.0", "stylelint-config-standard": "^34.0.0" }, "peerDependencies": { @@ -15232,12 +14872,12 @@ } }, "node_modules/stylelint-scss": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-5.2.1.tgz", - "integrity": "sha512-ZoTJUM85/qqpQHfEppjW/St//8s6p9Qsg8deWlYlr56F9iUgC9vXeIDQvH4odkRRJLTLFQzYMALSOFCQ3MDkgw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-5.3.2.tgz", + "integrity": "sha512-4LzLaayFhFyneJwLo0IUa8knuIvj+zF0vBFueQs4e3tEaAMIQX8q5th8ziKkgOavr6y/y9yoBe+RXN/edwLzsQ==", "dev": true, "dependencies": { - "known-css-properties": "^0.28.0", + "known-css-properties": "^0.29.0", "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.1", "postcss-selector-parser": "^6.0.13", @@ -15247,10 +14887,10 @@ "stylelint": "^14.5.1 || ^15.0.0" } }, - "node_modules/stylelint-scss/node_modules/known-css-properties": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.28.0.tgz", - "integrity": "sha512-9pSL5XB4J+ifHP0e0jmmC98OGC1nL8/JjS+fi6mnTlIf//yt/MfVLtKg7S6nCtj/8KTcWX7nRlY0XywoYY1ISQ==", + "node_modules/stylelint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, "node_modules/stylelint/node_modules/balanced-match": { @@ -15259,54 +14899,54 @@ "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", "dev": true }, - "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.1.tgz", - "integrity": "sha512-uLfFktPmRetVCbHe5UPuekWrQ6hENufnA46qEGbfACkK5drjTTdQYUragRgMjHldcbYG+nslUerqMPjbBSHXjQ==", - "dev": true, - "dependencies": { - "flat-cache": "^3.1.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/stylelint/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/stylelint/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/stylelint/node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.2.tgz", + "integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==", "dev": true, + "dependencies": { + "flat-cache": "^3.2.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12.0.0" } }, - "node_modules/stylelint/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/stylelint/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "engines": { - "node": ">=8" + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, "node_modules/supports-color": { @@ -15606,6 +15246,28 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -15688,12 +15350,12 @@ } }, "node_modules/ts-api-utils": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", - "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "dev": true, "engines": { - "node": ">=16.13.0" + "node": ">=16" }, "peerDependencies": { "typescript": ">=4.2.0" @@ -15776,9 +15438,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -15789,9 +15451,9 @@ } }, "node_modules/ua-parser-js": { - "version": "0.7.36", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.36.tgz", - "integrity": "sha512-CPPLoCts2p7D8VbybttE3P2ylv0OBZEAy7a12DsulIEcAiMtWJy+PBgMXgWDI80D5UwqE8oQPHYnk13tm38M2Q==", + "version": "0.7.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.37.tgz", + "integrity": "sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==", "dev": true, "funding": [ { @@ -15894,12 +15556,12 @@ } }, "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unpipe": { @@ -16756,6 +16418,34 @@ "ajv": "^6.9.1" } }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "node_modules/webpack/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -16804,15 +16494,18 @@ } }, "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "dependencies": { "isexe": "^2.0.0" }, "bin": { - "which": "bin/which" + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" } }, "node_modules/wildcard": { @@ -16822,9 +16515,9 @@ "dev": true }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, "dependencies": { "ansi-styles": "^4.0.0", @@ -16832,10 +16525,7 @@ "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/wrap-ansi-cjs": { @@ -17017,21 +16707,21 @@ } }, "node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "engines": { - "node": ">=12.20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/zone.js": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.2.tgz", - "integrity": "sha512-X4U7J1isDhoOmHmFWiLhloWc2lzMkdnumtfQ1LXzf/IOZp5NQYuMUTaviVzG/q1ugMBIXzin2AqeVJUoSEkNyQ==", + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.4.tgz", + "integrity": "sha512-NtTUvIlNELez7Q1DzKVIFZBzNb646boQMgpATo9z3Ftuu/gWvzxCW7jdjcUDoRGxRikrhVHB/zLXh1hxeJawvw==", "dependencies": { "tslib": "^2.3.0" } diff --git a/package.json b/package.json index 0a4b537..98b305b 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "@angular/platform-browser-dynamic": "^17.2.3", "@angular/router": "^17.2.3", "buffer": "^6.0.3", + "rsocket-adapter-rxjs": "^1.0.0-alpha.4", "rsocket-composite-metadata": "^1.0.0-alpha.3", "rsocket-core": "^1.0.0-alpha.3", + "rsocket-messaging": "^1.0.0-alpha.3", "rsocket-websocket-client": "^1.0.0-alpha.3", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -49,7 +51,6 @@ "eslint-config-prettier": "^9.0.0", "husky": "^8.0.3", "jasmine-core": "~4.6.0", - "jasmine-marbles": "^0.9.2", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", @@ -59,6 +60,6 @@ "stylelint": "^15.11.0", "stylelint-config-standard": "^34.0.0", "stylelint-config-standard-scss": "^11.0.0", - "typescript": "~5.2.2" + "typescript": "~5.3.0" } } diff --git a/src/app/app-tokens.ts b/src/app/app-tokens.ts deleted file mode 100644 index 44f860b..0000000 --- a/src/app/app-tokens.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { InjectionToken } from "@angular/core"; -import { AbstractRetryService } from "./services/retry/abstractRetry.service"; - -export const RETRY_DEFAULT = new InjectionToken( - "RetryDefault", -); -export const RETRY_FOREVER = new InjectionToken( - "RetryForever", -); diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 81cad6b..2808ca7 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,23 +1,16 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { AppComponent } from "./app.component"; +import { ConfigService } from "./config/config.service"; import { RouterTestingModule } from "@angular/router/testing"; -import { NavComponent } from "./shared/nav/nav.component"; -import { FooterComponent } from "./shared/footer/footer.component"; -import { NotificationService } from "./services/user/notification.service"; describe("AppComponent", () => { let fixture: ComponentFixture; let appComponentInstance: AppComponent; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - NavComponent, - FooterComponent, - AppComponent, - ], - providers: [{ provide: NotificationService, useValue: {} }], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [AppComponent, RouterTestingModule], + providers: [{ provide: ConfigService, useValue: {} }], }).compileComponents(); fixture = TestBed.createComponent(AppComponent); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5a688d8..97e88eb 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,7 +2,7 @@ import { Component, HostBinding } from "@angular/core"; import { RouterOutlet } from "@angular/router"; import { NavComponent } from "./shared/nav/nav.component"; import { FooterComponent } from "./shared/footer/footer.component"; -import { NotificationService } from "./services/user/notification.service"; +import { RsocketService } from "./services/network/rsocket/rsocket.service"; @Component({ selector: "app-root", @@ -14,5 +14,7 @@ export class AppComponent { title = "groupHQ-UI"; @HostBinding("class") classes = "page-container"; - constructor(readonly notificationService: NotificationService) {} + constructor(private readonly rsocketService: RsocketService) { + this.rsocketService.initializeRsocketConnection(); + } } diff --git a/src/app/config/config.service.ts b/src/app/config/config.service.ts index 4265d96..0d9242d 100644 --- a/src/app/config/config.service.ts +++ b/src/app/config/config.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from "@angular/core"; import { APP_CONFIG, Config } from "./config"; +import { RetryOptions } from "../services/retry/retry.options"; @Injectable({ providedIn: "root", @@ -62,4 +63,29 @@ export class ConfigService { public get apiProtocol() { return this.config.api.protocol; } + + public get retryDefaultStrategy(): RetryOptions { + return { + MAX_ATTEMPTS: this.config.retryServices.retryDefault.MAX_ATTEMPTS ?? 5, + MIN_RETRY_INTERVAL: + this.config.retryServices.retryDefault.MIN_RETRY_INTERVAL ?? 5, + MAX_RETRY_INTERVAL: + this.config.retryServices.retryDefault.MAX_RETRY_INTERVAL ?? 5, + }; + } + + public get retryForeverStrategy(): RetryOptions { + const MAX_RETRY_ATTEMPTS = + this.config.retryServices.retryForeverConstant.MAX_ATTEMPTS == -1 + ? Number.MAX_VALUE + : this.config.retryServices.retryForeverConstant.MAX_ATTEMPTS; + + return { + MAX_ATTEMPTS: MAX_RETRY_ATTEMPTS ?? Number.MAX_VALUE, + MIN_RETRY_INTERVAL: + this.config.retryServices.retryForeverConstant.MIN_RETRY_INTERVAL ?? 5, + MAX_RETRY_INTERVAL: + this.config.retryServices.retryForeverConstant.MAX_RETRY_INTERVAL ?? 5, + }; + } } diff --git a/src/app/config/config.ts b/src/app/config/config.ts index 23b21d6..d00c391 100644 --- a/src/app/config/config.ts +++ b/src/app/config/config.ts @@ -1,4 +1,4 @@ -import { RetryServiceOptions } from "../services/retry/abstractRetry.service"; +import { RetryOptions } from "../services/retry/retry.options"; import { InjectionToken } from "@angular/core"; export type Config = { @@ -19,8 +19,8 @@ export type Config = { maximumDisconnectRetryTime: number; }; retryServices: { - retryDefault: RetryServiceOptions; - retryForeverConstant: RetryServiceOptions; + retryDefault: RetryOptions; + retryForeverConstant: RetryOptions; }; groupBoardComponent: { loadingDelaySeconds: number; diff --git a/src/app/groups/dialogs/groupDetailsDialog/date-ago.pipe.spec.ts b/src/app/groups/dialogs/groupDetailsDialog/date-ago.pipe.spec.ts new file mode 100644 index 0000000..86195bd --- /dev/null +++ b/src/app/groups/dialogs/groupDetailsDialog/date-ago.pipe.spec.ts @@ -0,0 +1,50 @@ +import { DateAgoPipe } from "./date-ago.pipe"; + +describe("DateAgoPipe", () => { + let pipe: DateAgoPipe; + + beforeEach(() => { + pipe = new DateAgoPipe(); + }); + + it("should return singular hour description when time since is one hour", () => { + const date = new Date(); + date.setHours(date.getHours() - 1); + const timeSince = pipe.transform(date.toISOString()); + expect(timeSince).toBe("1 hour ago"); + }); + + it("should return plural hour description when time since is multiple hours", () => { + const date = new Date(); + date.setHours(date.getHours() - 2); + const timeSince = pipe.transform(date.toISOString()); + expect(timeSince).toBe("2 hours ago"); + }); + + it("should return singular minute description when time since is one minute", () => { + const date = new Date(); + date.setMinutes(date.getMinutes() - 1); + const timeSince = pipe.transform(date.toISOString()); + expect(timeSince).toBe("1 minute ago"); + }); + + it("should return plural minute description when time since is multiple minutes", () => { + const date = new Date(); + date.setMinutes(date.getMinutes() - 2); + const timeSince = pipe.transform(date.toISOString()); + expect(timeSince).toBe("2 minutes ago"); + }); + + it("should return generic plural second description when time since is in seconds", () => { + const date = new Date(); + date.setSeconds(date.getSeconds() - 2); + const timeSince = pipe.transform(date.toISOString()); + expect(timeSince).toBe("a few seconds ago"); + }); + + it("should throw an error when time since is negative", () => { + const date = new Date(); + date.setSeconds(date.getSeconds() + 1); + expect(() => pipe.transform(date.toISOString())).toThrowError(); + }); +}); diff --git a/src/app/groups/dialogs/groupDetailsDialog/date-ago.pipe.ts b/src/app/groups/dialogs/groupDetailsDialog/date-ago.pipe.ts new file mode 100644 index 0000000..d663bb3 --- /dev/null +++ b/src/app/groups/dialogs/groupDetailsDialog/date-ago.pipe.ts @@ -0,0 +1,37 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: "dateAgo", + standalone: true, + pure: false, +}) +export class DateAgoPipe implements PipeTransform { + transform(date: string): string { + return this.timeSince(date); + } + + private timeSince(date: string): string { + const seconds = Math.floor( + (new Date().getTime() - new Date(date).getTime()) / 1000, + ); + + if (seconds < 0) { + throw new Error("Date must be in the past"); + } + + let interval: number; + + interval = seconds / 3600; + if (interval >= 1) { + const floored = Math.floor(interval); + return floored === 1 ? "1 hour ago" : floored + " hours ago"; + } + interval = seconds / 60; + if (interval >= 1) { + const floored = Math.floor(interval); + return floored === 1 ? "1 minute ago" : floored + " minutes ago"; + } + + return "a few seconds ago"; + } +} diff --git a/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.html b/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.html index a9b1f38..841e72e 100644 --- a/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.html +++ b/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.html @@ -14,12 +14,12 @@

Created

-

{{ timeSince(group.createdDate) }}

+

{{ group.createdDate | dateAgo }}

Last Activity

- {{ timeSince(group.lastModifiedDate) }} + {{ group.lastModifiedDate | dateAgo }}

@@ -40,32 +40,30 @@ Members @for (member of group.members; track member) { - - account_circle -

{{ member.username }}

-

- Joined {{ timeSince(member.joinedDate) }} -

-
+ + account_circle +

+ {{ member.username }} +

+

+ Joined {{ member.joinedDate | dateAgo }} +

+
} @empty { - -

No members yet

-
+ +

No members yet

+
} - @if (errorLeavingGroup) { -
- Error joining group. Please try again -
- } + No members yet > Close - @if (!userService.currentGroupId) { @if (loading) { -
- - -
- } @else { - - } } @else { @if (loading) { -
- -
+ @if (!userService.currentGroupId) { + @if (loading) { +
+ + +
+ } @else { + + } } @else { - - } } + @if (loading) { +
+ +
+ } @else { + + } + }
diff --git a/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.spec.ts b/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.spec.ts index 363a1ae..46e9f42 100644 --- a/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.spec.ts +++ b/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.spec.ts @@ -1,228 +1,71 @@ +import { GroupDetailsDialogComponent } from "./groupDetailsDialog.component"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { MAT_DIALOG_DATA, - MatDialogModule, MatDialogRef, MatDialogState, } from "@angular/material/dialog"; -import { NoopAnimationsModule } from "@angular/platform-browser/animations"; -import { GroupDetailsDialogComponent } from "./groupDetailsDialog.component"; +import { AsynchronousRequestMediator } from "../../../services/notifications/asynchronousRequest.mediator"; +import { UserService } from "../../../services/user/user.service"; +import { ConfigService } from "../../../config/config.service"; +import { GroupModel } from "../../../model/group.model"; import { MemberModel } from "../../../model/member.model"; -import { MemberStatusEnum } from "../../../model/enums/memberStatus.enum"; -import { GroupStatusEnum } from "../../../model/enums/groupStatus.enum"; -import { MatSnackBarModule } from "@angular/material/snack-bar"; -import { MatListModule } from "@angular/material/list"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { NEVER } from "rxjs"; import { GroupManagerService } from "../../services/groupManager.service"; -import { GroupModel } from "../../../model/group.model"; -import { UserService } from "../../../services/user/user.service"; -import { RsocketRequestsService } from "../../../services/network/rsocket/requests/rsocketRequests.service"; -import { RsocketPrivateUpdateStreamService } from "../../../services/network/rsocket/streams/rsocketPrivateUpdateStream.service"; -import { cold, getTestScheduler } from "jasmine-marbles"; -import { PrivateEventModel } from "../../../model/privateEvent.model"; -import { v4 as uuidv4 } from "uuid"; -import { AggregateTypeEnum } from "../../../model/enums/aggregateType.enum"; -import { EventTypeEnum } from "../../../model/enums/eventType.enum"; -import { EventStatusEnum } from "../../../model/enums/eventStatus.enum"; - -const mockMember: MemberModel = { - id: 1, - username: "Test User", - groupId: 1, - memberStatus: MemberStatusEnum.ACTIVE, - joinedDate: new Date().toISOString(), - exitedDate: null, -}; - -const mockPrivateEvent: PrivateEventModel = { - eventId: uuidv4(), - aggregateId: 1, - websocketId: uuidv4(), - aggregateType: AggregateTypeEnum.GROUP, - eventType: EventTypeEnum.MEMBER_LEFT, - eventData: JSON.stringify(mockMember), - eventStatus: EventStatusEnum.SUCCESSFUL, - createdDate: new Date().toISOString(), -}; describe("GroupDetailsDialogComponent", () => { let component: GroupDetailsDialogComponent; let fixture: ComponentFixture; let dialogRefStub: jasmine.SpyObj>; - let userService: jasmine.SpyObj; - let rsocketRequestsServiceSpy: jasmine.SpyObj; - let rsocketPrivateUpdateStreamServiceSpy: jasmine.SpyObj; + let userService: UserService; + let groupManagerService: GroupManagerService; + let asyncRequestMediator: AsynchronousRequestMediator; let page: Page; - - const members: MemberModel[] = [ - new MemberModel( - 1, - "Brooks Foley", - 1, - MemberStatusEnum.ACTIVE, - new Date().toISOString(), - null, - ), - new MemberModel( - 2, - "Test User", - 1, - MemberStatusEnum.ACTIVE, - new Date().toISOString(), - null, - ), - new MemberModel( - 3, - "Another User", - 1, - MemberStatusEnum.ACTIVE, - new Date().toISOString(), - null, - ), - ]; - const group: GroupModel = new GroupModel( - 1, - "Farming For Gold", - "Let's meet at the Dwarven Mines south entrance.", - 6, - new Date().toISOString(), - new Date().toISOString(), - "Test User", - "Test User", - 1, - GroupStatusEnum.ACTIVE, - members, - ); - - beforeEach(async () => { - dialogRefStub = jasmine.createSpyObj("MatDialogRef", ["close"]); - - userService = { - get currentGroupId$() { - return cold("a", { a: group.id }); - }, - get currentGroupId() { - return group.id; - }, + let testScheduler: TestScheduler; + + beforeEach(() => { + const members: Partial[] = [ + { username: "Brooks Foley" }, + { username: "Test User" }, + { username: "Another User" }, + ]; + const group: Partial = { + id: 1, + title: "Farming For Gold", + description: "Let's meet at the Dwarven Mines south entrance.", + maxGroupSize: 6, + members: members as MemberModel[], }; - rsocketRequestsServiceSpy = jasmine.createSpyObj("RsocketRequestsService", [ - "sendLeaveRequest", - ]); - rsocketPrivateUpdateStreamServiceSpy = jasmine.createSpyObj( - "RsocketPrivateUpdateStreamService", - ["initializePrivateUpdateStream"], - ); - Object.defineProperty( - rsocketPrivateUpdateStreamServiceSpy, - "isPrivateUpdatesStreamReady", - { - get: () => true, - configurable: true, - }, - ); - Object.defineProperty( - rsocketPrivateUpdateStreamServiceSpy, - "privateUpdatesStream$", - { - get: () => cold("a", { a: mockPrivateEvent }), - configurable: true, - }, - ); + dialogRefStub = jasmine.createSpyObj("MatDialogRef", ["close"]); - await TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - MatSnackBarModule, - MatDialogModule, - MatListModule, - GroupDetailsDialogComponent, - ], + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], providers: [ { provide: MatDialogRef, useValue: dialogRefStub }, { provide: MAT_DIALOG_DATA, useValue: group }, - { provide: UserService, useValue: userService }, - { - provide: RsocketRequestsService, - useValue: rsocketRequestsServiceSpy, - }, - { - provide: RsocketPrivateUpdateStreamService, - useValue: rsocketPrivateUpdateStreamServiceSpy, - }, - GroupManagerService, + { provide: ConfigService, useValue: {} }, ], - }).compileComponents(); - }); - - describe("#timeSince", () => { - beforeEach(() => { - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; }); - it("should return singular hour description when time since is one hour", () => { - const date = new Date(); - date.setHours(date.getHours() - 1); - const timeSince = component.timeSince(date.toISOString()); - expect(timeSince).toBe("1 hour ago"); - }); - - it("should return plural hour description when time since is multiple hours", () => { - const date = new Date(); - date.setHours(date.getHours() - 2); - const timeSince = component.timeSince(date.toISOString()); - expect(timeSince).toBe("2 hours ago"); - }); - - it("should return singular minute description when time since is one minute", () => { - const date = new Date(); - date.setMinutes(date.getMinutes() - 1); - const timeSince = component.timeSince(date.toISOString()); - expect(timeSince).toBe("1 minute ago"); - }); - - it("should return plural minute description when time since is multiple minutes", () => { - const date = new Date(); - date.setMinutes(date.getMinutes() - 2); - const timeSince = component.timeSince(date.toISOString()); - expect(timeSince).toBe("2 minutes ago"); - }); - - it("should return singular second description when time since is one second", () => { - const date = new Date(); - date.setSeconds(date.getSeconds() - 1); - const timeSince = component.timeSince(date.toISOString()); - expect(timeSince).toBe("1 second ago"); - }); + fixture = TestBed.createComponent(GroupDetailsDialogComponent); + component = fixture.componentInstance; + userService = TestBed.inject(UserService); + groupManagerService = TestBed.inject(GroupManagerService); + asyncRequestMediator = TestBed.inject(AsynchronousRequestMediator); + page = new Page(fixture); - it("should return plural second description when time since is multiple seconds", () => { - const date = new Date(); - date.setSeconds(date.getSeconds() - 2); - const timeSince = component.timeSince(date.toISOString()); - expect(timeSince).toBe("2 seconds ago"); - }); - - it("should return zero seconds ago when time since is negative", () => { - const date = new Date(); - date.setSeconds(date.getSeconds() + 1); - const timeSince = component.timeSince(date.toISOString()); - expect(timeSince).toBe("0 seconds ago"); + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); }); }); describe("group details", () => { - beforeEach(() => { - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; + it("should display the group details", () => { fixture.detectChanges(); - page = new Page(fixture); - }); - it("creates the component", () => { - expect(component).toBeTruthy(); - }); - - it("should display the group details", () => { expect(page.title?.textContent).toContain("Farming For Gold"); expect(page.description?.textContent).toContain( "Let's meet at the Dwarven Mines south entrance.", @@ -231,11 +74,13 @@ describe("GroupDetailsDialogComponent", () => { }); it("should allow the user to close the group details dialog", () => { - component.onNoClick(); + page.clickCloseButton(); expect(dialogRefStub.close.calls.count()).toBe(1); }); it("should display all the group members", () => { + fixture.detectChanges(); + const members = page.members; expect(members.length).toBe(3); @@ -245,52 +90,12 @@ describe("GroupDetailsDialogComponent", () => { }); it("should update the group members when the group is updated", () => { - component.group = new GroupModel( - 1, - "Farming For Gold", - "Let's meet at the Dwarven Mines south entrance.", - 6, - new Date().toISOString(), - new Date().toISOString(), - "Test User", - "Test User", - 1, - GroupStatusEnum.ACTIVE, - [ - new MemberModel( - 1, - "Brooks Foley", - 1, - MemberStatusEnum.ACTIVE, - new Date().toISOString(), - null, - ), - new MemberModel( - 2, - "Test User", - 1, - MemberStatusEnum.ACTIVE, - new Date().toISOString(), - null, - ), - new MemberModel( - 3, - "Another User", - 1, - MemberStatusEnum.ACTIVE, - new Date().toISOString(), - null, - ), - new MemberModel( - 4, - "New User", - 1, - MemberStatusEnum.ACTIVE, - new Date().toISOString(), - null, - ), - ], - ); + const newMember: Partial = { username: "New User" }; + component.group = { + ...component.group, + members: [...component.group.members, newMember as MemberModel], + }; + fixture.detectChanges(); expect(page.membersCount?.textContent).toContain("4 / 6"); @@ -305,69 +110,72 @@ describe("GroupDetailsDialogComponent", () => { }); }); - describe("group actions", () => { - function createAndFlushComponent() { - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; - getTestScheduler().flush(); - fixture.detectChanges(); - page = new Page(fixture); - } + describe("group updates", () => { + it("should keep the group up to date via group manager service", () => { + testScheduler.run(({ cold, flush }) => { + const updatedGroup: Partial = { + ...component.group, + title: "New Title", + }; - it("should show the join group button when the user is not part of the group", () => { - Object.defineProperty(userService, "currentGroupId", { - get: () => null, + spyOnProperty(groupManagerService, "groups$", "get").and.returnValue( + cold("a", { a: [{} as any, {} as any, updatedGroup] }), + ); + + fixture.detectChanges(); + + expect(page.title?.textContent).toContain("Farming For Gold"); + + flush(); + + fixture.detectChanges(); + + expect(page.title?.textContent).toContain("New Title"); }); + }); + }); + + describe("group actions", () => { + it("should show the join group button when the user is not part of the group", () => { + userService.removeUserFromGroup(); - createAndFlushComponent(); + fixture.detectChanges(); expect(page.actionButtonType).toBe(ActionStates.JOIN_GROUP); }); it("should disable the join group button when the user is already part of another group", () => { - Object.defineProperty(userService, "currentGroupId", { - get: () => component.group.id + 1, - }); + userService.setUserInGroup(component.group.id + 1, 1); - createAndFlushComponent(); + fixture.detectChanges(); expect(page.actionButtonType).toBe(ActionStates.IN_ANOTHER_GROUP); expect(page.isActionButtonDisabled).toBeTrue(); }); it("should disable the join group button when the group is full", () => { - const updatedGroup = new GroupModel( - 1, - "Farming For Gold", - "Let's meet at the Dwarven Mines south entrance.", - 3, - new Date().toISOString(), - new Date().toISOString(), - "Test User", - "Test User", - 1, - GroupStatusEnum.ACTIVE, - members, + const members: Partial[] = Array.from( + { length: component.group.maxGroupSize }, + (_, i) => ({ + username: `User ${i + 1}`, + }), ); - Object.defineProperty(userService, "currentGroupId", { - get: () => null, - }); + component.group = { + ...component.group, + members: members as MemberModel[], + }; + userService.removeUserFromGroup(); - createAndFlushComponent(); - component.group = updatedGroup; fixture.detectChanges(); - expect(page.actionButtonType).toBe(ActionStates.GROUP_FULL); expect(page.isActionButtonDisabled).toBeTrue(); }); it("should open the input dialog when the user clicks the join group button", () => { - Object.defineProperty(userService, "currentGroupId", { - get: () => null, - }); + userService.removeUserFromGroup(); - createAndFlushComponent(); + fixture.detectChanges(); expect(page.actionButtonType).toBe(ActionStates.JOIN_GROUP); page.clickActionButton(); @@ -376,160 +184,82 @@ describe("GroupDetailsDialogComponent", () => { }); it("should close the input dialog when the user joins a group", () => { - Object.defineProperty(userService, "currentGroupId", { - get: () => null, - }); - - Object.defineProperty(userService, "currentGroupId$", { - get: () => cold("a", { a: 1 }), - }); - - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - page = new Page(fixture); - + // Dialog should be closed by default expect([MatDialogState.CLOSED, undefined, null]).toContain( component.inputNameDialogRef?.getState(), ); - expect(page.actionButtonType).toBe(ActionStates.JOIN_GROUP); - page.clickActionButton(); - - expect(component.inputNameDialogRef?.getState()).toBe( - MatDialogState.OPEN, - ); + userService.removeUserFromGroup(); - getTestScheduler().flush(); - expect(component.inputNameDialogRef?.getState()).toBeTruthy(); - expect([MatDialogState.CLOSING, MatDialogState.CLOSED]).toContain( - component.inputNameDialogRef!.getState(), - ); - }); - - it("should show a loading indicator when the user is leaving the group", () => { - createAndFlushComponent(); - - expect(page.actionButtonType).toBe(ActionStates.LEAVE_GROUP); - page.clickActionButton(); + testScheduler.run(({ cold, flush }) => { + spyOnProperty(userService, "currentGroupId$", "get").and.returnValue( + cold("a", { a: component.group.id }), + ); - expect(component.loading).toBeTrue(); - fixture.detectChanges(); - expect(page.isLoadingVisible).toBeTrue(); - }); + fixture.detectChanges(); - it("should show the leave group button when the user is part of the group", () => { - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - page = new Page(fixture); + expect(page.actionButtonType).toBe(ActionStates.JOIN_GROUP); + page.clickActionButton(); - expect(page.actionButtonType).toBe(ActionStates.LEAVE_GROUP); - expect(page.isActionButtonDisabled).toBeFalse(); - expect(component.loading).toBeFalse(); + expect(component.inputNameDialogRef?.getState()).toBe( + MatDialogState.OPEN, + ); - page.clickActionButton(); + flush(); - expect(component.loading).toBeTrue(); - fixture.detectChanges(); - expect(page.isLoadingVisible).toBeTrue(); + fixture.detectChanges(); - getTestScheduler().flush(); - expect(component.loading).toBeFalse(); - fixture.detectChanges(); - expect(page.isLoadingVisible).toBeFalse(); + expect(component.inputNameDialogRef?.getState()).toBeTruthy(); + expect([MatDialogState.CLOSING, MatDialogState.CLOSED]).toContain( + component.inputNameDialogRef!.getState(), + ); + }); }); - it("should show an error message if the leave request fails", () => { - Object.defineProperty( - rsocketPrivateUpdateStreamServiceSpy, - "privateUpdatesStream$", - { - get: () => - cold("a", { - a: { - ...mockPrivateEvent, - eventStatus: EventStatusEnum.FAILED, - }, - }), - }, - ); - - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; + it("should show the leave group button when the user is part of the group", () => { + userService.setUserInGroup(component.group.id, 1); fixture.detectChanges(); - page = new Page(fixture); expect(page.actionButtonType).toBe(ActionStates.LEAVE_GROUP); expect(page.isActionButtonDisabled).toBeFalse(); expect(component.loading).toBeFalse(); + }); - page.clickActionButton(); - - expect(component.loading).toBeTrue(); + it("should show a loading indicator when the user is leaving the group", () => { + spyOn(asyncRequestMediator, "submitRequestEvent").and.returnValue(NEVER); + userService.setUserInGroup(component.group.id, 1); fixture.detectChanges(); - expect(page.isLoadingVisible).toBeTrue(); - getTestScheduler().flush(); - expect(component.loading).toBeFalse(); - expect(component.errorLeavingGroup).toBeTrue(); - fixture.detectChanges(); - expect(page.isLoadingVisible).toBeFalse(); expect(page.actionButtonType).toBe(ActionStates.LEAVE_GROUP); - expect(page.isServerUnavailableErrorVisible).toBeTrue(); - }); - - it("should show an error message if the leave request times out", () => { - jasmine.clock().install(); - createAndFlushComponent(); - page.clickActionButton(); expect(component.loading).toBeTrue(); fixture.detectChanges(); expect(page.isLoadingVisible).toBeTrue(); - - jasmine.clock().tick(component.timeout + 1); - - expect(component.loading).toBeFalse(); - fixture.detectChanges(); - expect(page.isLoadingVisible).toBeFalse(); - - jasmine.clock().uninstall(); }); - it("should save the subscription and close it after receiving a response", () => { - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; + it("should set loading to false when the leave request completes", () => { + userService.setUserInGroup(component.group.id, 1); fixture.detectChanges(); - page = new Page(fixture); - page.clickActionButton(); - - expect(component.privateUpdateStreamSubscription).toBeTruthy(); - expect(component.privateUpdateStreamSubscription?.closed).toBeFalse(); - - getTestScheduler().flush(); - expect(component.privateUpdateStreamSubscription?.closed).toBeTrue(); - }); - - it("should unsubscribe from the subscription when the timeout is reached", () => { - jasmine.clock().install(); - - fixture = TestBed.createComponent(GroupDetailsDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - page = new Page(fixture); - page.clickActionButton(); + testScheduler.run(({ cold, flush }) => { + spyOn(asyncRequestMediator, "submitRequestEvent").and.returnValue( + cold("|"), + ); - expect(component.privateUpdateStreamSubscription).toBeTruthy(); - expect(component.privateUpdateStreamSubscription?.closed).toBeFalse(); + expect(page.actionButtonType).toBe(ActionStates.LEAVE_GROUP); + page.clickActionButton(); - jasmine.clock().tick(component.timeout + 1); + expect(component.loading).toBeTrue(); + fixture.detectChanges(); + expect(page.isLoadingVisible).toBeTrue(); - expect(component.privateUpdateStreamSubscription?.closed).toBeTrue(); + flush(); - jasmine.clock().uninstall(); + expect(component.loading).toBeFalse(); + fixture.detectChanges(); + expect(page.isLoadingVisible).toBeFalse(); + }); }); }); }); @@ -560,6 +290,18 @@ class Page { actionButton.click(); } + public clickCloseButton() { + const closeButton = this._element.querySelector( + '[data-test="close-group-details-dialog-button"]', + ); + + if (!closeButton) { + throw new Error("Close button not found"); + } + + closeButton.click(); + } + get actionButtonType(): ActionStates { const actionButton = this._element.querySelector( '[data-test="group-details-action-dialog-button"]', @@ -620,12 +362,4 @@ class Page { return loadingElement !== null; } - - get isServerUnavailableErrorVisible(): boolean { - const errorElement = this._element.querySelector( - "[data-test='server-unavailable-error']", - ); - - return errorElement !== null; - } } diff --git a/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.ts b/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.ts index f518a88..4acb242 100644 --- a/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.ts +++ b/src/app/groups/dialogs/groupDetailsDialog/groupDetailsDialog.component.ts @@ -2,14 +2,13 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { MAT_DIALOG_DATA, MatDialog, + MatDialogActions, + MatDialogClose, + MatDialogContent, MatDialogRef, MatDialogTitle, - MatDialogContent, - MatDialogClose, - MatDialogActions, } from "@angular/material/dialog"; import { GroupInputNameDialogComponent } from "../groupInputNameDialog/groupInputNameDialog.component"; -import { MatSnackBar } from "@angular/material/snack-bar"; import { GroupModel } from "../../../model/group.model"; import { Subscription } from "rxjs"; import { MatButtonModule } from "@angular/material/button"; @@ -17,14 +16,13 @@ import { MatIconModule } from "@angular/material/icon"; import { MatListModule } from "@angular/material/list"; import { AppMediaBreakpointDirective } from "../../../shared/directives/attr.breakpoint"; import { GroupManagerService } from "../../services/groupManager.service"; -import { RsocketRequestsService } from "../../../services/network/rsocket/requests/rsocketRequests.service"; import { UserService } from "../../../services/user/user.service"; import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; -import { RsocketPrivateUpdateStreamService } from "../../../services/network/rsocket/streams/rsocketPrivateUpdateStream.service"; -import { PrivateEventModel } from "../../../model/privateEvent.model"; -import { EventStatusEnum } from "../../../model/enums/eventStatus.enum"; -import { EventTypeEnum } from "../../../model/enums/eventType.enum"; import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { GroupLeaveRequestEvent } from "../../../model/requestevent/GroupLeaveRequestEvent"; +import { v4 as uuidv4 } from "uuid"; +import { AsynchronousRequestMediator } from "../../../services/notifications/asynchronousRequest.mediator"; +import { DateAgoPipe } from "./date-ago.pipe"; @Component({ selector: "app-group-details-dialog", @@ -42,35 +40,26 @@ import { MatProgressBarModule } from "@angular/material/progress-bar"; MatDialogClose, MatDialogActions, MatProgressBarModule, + DateAgoPipe, ], }) export class GroupDetailsDialogComponent implements OnInit, OnDestroy { private subscriptions = new Subscription(); - public inputNameDialogRef: MatDialogRef | null = null; - public isPrivateUpdateStreamReady: boolean = true; - public errorLeavingGroup: boolean = false; public loading: boolean = false; - public timeout: number = 5000; - public timeoutId: any; - public privateUpdateStreamSubscription: Subscription | null = null; constructor( - private _snackBar: MatSnackBar, - public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public group: GroupModel, public dialog: MatDialog, private readonly groupManagerService: GroupManagerService, public readonly userService: UserService, - private readonly rsocketRequestsService: RsocketRequestsService, - private readonly rsocketPrivateUpdateStreamService: RsocketPrivateUpdateStreamService, + private readonly asyncRequestMediator: AsynchronousRequestMediator, ) {} ngOnInit() { this.subscriptions.add( this.groupManagerService.groups$.subscribe((groups) => { - // Update the group data const updatedGroup = groups.find((g) => g.id === this.group.id); if (updatedGroup) { this.group = updatedGroup; @@ -78,99 +67,43 @@ export class GroupDetailsDialogComponent implements OnInit, OnDestroy { }), ); - this.userService.currentGroupId$.subscribe((groupId) => { - if (groupId) { - this.inputNameDialogRef?.close(); - } - }); + // Closes dialog to join group when a user is assigned to a group + this.subscriptions.add( + this.userService.currentGroupId$.subscribe((groupId) => { + if (groupId) { + this.inputNameDialogRef?.close(); + } + }), + ); } openInputNameDialog(): void { this.inputNameDialogRef = this.dialog.open(GroupInputNameDialogComponent, { data: this.group, }); - - this.inputNameDialogRef.afterClosed().subscribe((result) => { - if (result?.message) { - this._snackBar.open(result.message, undefined, { - duration: 4000, - horizontalPosition: "start", - }); - } - - console.debug("Input modal closed; no message"); - }); - } - - timeSince(date: string): string { - const seconds = Math.floor( - (new Date().getTime() - new Date(date).getTime()) / 1000, - ); - - if (seconds < 0) return "0 seconds ago"; - - let interval: number; - - interval = seconds / 3600; - if (interval >= 1) { - const floored = Math.floor(interval); - return floored === 1 ? "1 hour ago" : floored + " hours ago"; - } - interval = seconds / 60; - if (interval >= 1) { - const floored = Math.floor(interval); - return floored === 1 ? "1 minute ago" : floored + " minutes ago"; - } - const floored = Math.floor(seconds); - return floored === 1 ? "1 second ago" : floored + " seconds ago"; } leaveGroup(): void { - this.isPrivateUpdateStreamReady = - this.rsocketPrivateUpdateStreamService.isPrivateUpdatesStreamReady; - - if (this.isPrivateUpdateStreamReady) { - this.loading = true; - this.privateUpdateStreamSubscription = - this.rsocketPrivateUpdateStreamService.privateUpdatesStream$.subscribe( - (privateEvent) => { - this.handleLeaveGroupResponse(privateEvent); - }, - ); - - this.rsocketRequestsService.sendLeaveRequest( - this.group.id, - this.userService.currentMemberId!, - this.userService.uuid, - ); - - this.timeoutId = setTimeout(() => { - if (this.loading) { - this.loading = false; - this.errorLeavingGroup = true; - this.privateUpdateStreamSubscription?.unsubscribe(); - } - }, this.timeout); + if (!this.userService.currentMemberId) { + throw new Error("User is not a member of any group"); } - } - private handleLeaveGroupResponse(privateEvent: PrivateEventModel) { - if ( - privateEvent.eventType === EventTypeEnum.MEMBER_LEFT && - privateEvent.aggregateId === this.group.id - ) { - this.loading = false; - this.privateUpdateStreamSubscription?.unsubscribe(); - clearTimeout(this.timeoutId); + const leaveRequest: GroupLeaveRequestEvent = new GroupLeaveRequestEvent( + uuidv4(), + this.group.id, + this.userService.uuid, + new Date().toISOString(), + this.userService.currentMemberId, + ); - if (privateEvent.eventStatus !== EventStatusEnum.SUCCESSFUL) { - this.errorLeavingGroup = true; - } - } - } + this.loading = true; - onNoClick(): void { - this.dialogRef.close(); + console.debug("Leaving group: ", leaveRequest); + this.asyncRequestMediator + .submitRequestEvent(leaveRequest, "groups.leave", "groups.updates.user") + .subscribe({ + complete: () => (this.loading = false), + }); } ngOnDestroy() { diff --git a/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.html b/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.html index 3a1e390..13ed175 100644 --- a/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.html +++ b/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.html @@ -32,14 +32,6 @@ - @if (!this.isPrivateUpdateStreamReady) { -
Error receiving messages from the server
- } @else if (errorJoiningGroup) { -
- Error joining group. Please try again -
- } -
Close - @if (!userService.currentGroupId) { @if (loading) { -
- -
- } @else { - - } } + @if (!userService.currentGroupId) { + @if (loading) { +
+ +
+ } @else { + + } + }
diff --git a/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.spec.ts b/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.spec.ts index 7d4e936..288a0d4 100644 --- a/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.spec.ts +++ b/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.spec.ts @@ -1,143 +1,41 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; import { GroupInputNameDialogComponent } from "./groupInputNameDialog.component"; -import { MatFormFieldModule } from "@angular/material/form-field"; -import { RsocketRequestsService } from "../../../services/network/rsocket/requests/rsocketRequests.service"; -import { RsocketPrivateUpdateStreamService } from "../../../services/network/rsocket/streams/rsocketPrivateUpdateStream.service"; -import { UserService } from "../../../services/user/user.service"; -import { PrivateEventModel } from "../../../model/privateEvent.model"; -import { v4 as uuidv4 } from "uuid"; -import { AggregateTypeEnum } from "../../../model/enums/aggregateType.enum"; -import { EventTypeEnum } from "../../../model/enums/eventType.enum"; -import { MemberModel } from "../../../model/member.model"; -import { MemberStatusEnum } from "../../../model/enums/memberStatus.enum"; -import { EventStatusEnum } from "../../../model/enums/eventStatus.enum"; -import { cold, getTestScheduler } from "jasmine-marbles"; -import { GroupModel } from "../../../model/group.model"; -import { GroupStatusEnum } from "../../../model/enums/groupStatus.enum"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { AsynchronousRequestMediator } from "../../../services/notifications/asynchronousRequest.mediator"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; -import { MatInputModule } from "@angular/material/input"; -import { ReactiveFormsModule } from "@angular/forms"; -import { MatButtonModule } from "@angular/material/button"; - -const mockGroup: GroupModel = { - id: 1, - title: "Test Group", - description: "Test Group Description", - maxGroupSize: 10, - createdDate: new Date().toISOString(), - lastModifiedDate: new Date().toISOString(), - createdBy: "Test User", - lastModifiedBy: "Test User", - version: 1, - status: GroupStatusEnum.ACTIVE, - members: [], -}; - -const mockMember: MemberModel = { - id: 1, - username: "Test User", - groupId: 1, - memberStatus: MemberStatusEnum.ACTIVE, - joinedDate: new Date().toISOString(), - exitedDate: null, -}; - -const mockPrivateEvent: PrivateEventModel = { - eventId: uuidv4(), - aggregateId: 1, - websocketId: uuidv4(), - aggregateType: AggregateTypeEnum.GROUP, - eventType: EventTypeEnum.MEMBER_JOINED, - eventData: JSON.stringify(mockMember), - eventStatus: EventStatusEnum.SUCCESSFUL, - createdDate: new Date().toISOString(), -}; +import { ConfigService } from "../../../config/config.service"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { GroupJoinRequestEvent } from "../../../model/requestevent/GroupJoinRequestEvent"; describe("GroupInputNameDialogComponent", () => { let component: GroupInputNameDialogComponent; let fixture: ComponentFixture; let dialogRefSpy: jasmine.SpyObj>; - let userService: jasmine.SpyObj; - let rsocketRequestsServiceSpy: jasmine.SpyObj; - let rsocketPrivateUpdateStreamServiceSpy: jasmine.SpyObj; + let asyncRequestMediator: AsynchronousRequestMediator; let page: Page; + const groupId = 1; + let testScheduler: TestScheduler; - beforeEach(async () => { + beforeEach(() => { dialogRefSpy = jasmine.createSpyObj("MatDialogRef", ["close"]); - userService = { - mockUuid: uuidv4(), - get uuid(): string { - return this.mockUuid; - }, - }; - - rsocketRequestsServiceSpy = jasmine.createSpyObj("RsocketRequestsService", [ - "sendJoinRequest", - ]); - rsocketPrivateUpdateStreamServiceSpy = jasmine.createSpyObj( - "RsocketPrivateUpdateStreamService", - ["initializePrivateUpdateStream"], - ); - Object.defineProperty( - rsocketPrivateUpdateStreamServiceSpy, - "isPrivateUpdatesStreamReady", - { - get: () => true, - configurable: true, - }, - ); - Object.defineProperty( - rsocketPrivateUpdateStreamServiceSpy, - "privateUpdatesStream$", - { - get: () => cold("a", { a: mockPrivateEvent }), - }, - ); - - await TestBed.configureTestingModule({ - imports: [ - MatInputModule, - MatFormFieldModule, - ReactiveFormsModule, - MatButtonModule, - GroupInputNameDialogComponent, - NoopAnimationsModule, - ], + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], providers: [ + { provide: MAT_DIALOG_DATA, useValue: { id: groupId } }, { provide: MatDialogRef, useValue: dialogRefSpy }, - { provide: MAT_DIALOG_DATA, useValue: mockGroup }, - { provide: UserService, useValue: userService }, - { - provide: RsocketRequestsService, - useValue: rsocketRequestsServiceSpy, - }, - { - provide: RsocketPrivateUpdateStreamService, - useValue: rsocketPrivateUpdateStreamServiceSpy, - }, + { provide: ConfigService, useValue: {} }, ], - }).compileComponents(); + }); fixture = TestBed.createComponent(GroupInputNameDialogComponent); component = fixture.componentInstance; + asyncRequestMediator = TestBed.inject(AsynchronousRequestMediator); page = new Page(fixture); - }); - - beforeEach(() => { - jasmine.clock().install(); - }); - - afterEach(() => { - jasmine.clock().uninstall(); - }); - it("should create", () => { - expect(component).toBeTruthy(); - expect(component.loading).toBe(false); - expect(component.isPrivateUpdateStreamReady).toBe(true); - expect(component.errorJoiningGroup).toBe(false); + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); }); describe("joinGroup", () => { @@ -146,186 +44,104 @@ describe("GroupInputNameDialogComponent", () => { fixture.detectChanges(); }); - it("should let the user submit their name", () => { + it("should disable the join button if the name field is empty", () => { component.nameField.setValue(""); fixture.detectChanges(); expect(page.isJoinButtonDisabled).toBeTrue(); + }); - component.nameField.setValue("Test User"); + it("should disable the join button if attempting to submit a name with only white space", () => { + component.nameField.setValue(" "); fixture.detectChanges(); - - expect(page.isJoinButtonEnabled).toBeTrue(); page.submitName(); - - expect( - rsocketRequestsServiceSpy.sendJoinRequest, - ).toHaveBeenCalledOnceWith( - component.nameField.value, - mockGroup.id, - userService.uuid, - ); - - getTestScheduler().flush(); - - expect(dialogRefSpy.close).toHaveBeenCalledTimes(1); - }); - - it("should not let the user submit an invalid name", () => { - component.nameField.setValue(""); - fixture.detectChanges(); - expect(page.isJoinButtonDisabled).toBeTrue(); - component.joinGroup(); - - expect(rsocketRequestsServiceSpy.sendJoinRequest).toHaveBeenCalledTimes( - 0, - ); - - getTestScheduler().flush(); - - expect(dialogRefSpy.close.calls.count()).toBe(0); - expect(component.nameField.hasError("required")).toBeTrue(); + expect(page.isJoinButtonDisabled).toBeTrue(); + expect(page.isMemberNameRequiredErrorVisible).toBeTrue(); }); - it("should not let the user submit a name with only spaces", () => { - component.nameField.setValue(" "); + it("should enable the join button if the name field is not empty", () => { + component.nameField.setValue("A"); fixture.detectChanges(); + expect(page.isJoinButtonEnabled).toBeTrue(); + }); - expect(page.isJoinButtonDisabled).toBeFalse(); - - component.joinGroup(); + it("should let the user submit their name", () => { + component.nameField.setValue("Test User"); fixture.detectChanges(); - expect(page.isJoinButtonDisabled).toBeTrue(); - - expect(rsocketRequestsServiceSpy.sendJoinRequest).toHaveBeenCalledTimes( - 0, - ); - - getTestScheduler().flush(); - - expect(dialogRefSpy.close.calls.count()).toBe(0); - expect(component.nameField.hasError("required")).toBeTrue(); - }); + testScheduler.run(({ cold, flush }) => { + spyOn(asyncRequestMediator, "submitRequestEvent").and.returnValue( + cold("|"), + ); + page.submitName(); - it("should save the subscription and close it after receiving a response", () => { - page.submitName(); + flush(); - expect(component.subscription).toBeTruthy(); - expect(component.subscription?.closed).toBeFalse(); + expect( + asyncRequestMediator.submitRequestEvent, + ).toHaveBeenCalledOnceWith( + jasmine.any(GroupJoinRequestEvent), + "groups.join", + "groups.updates.user", + ); - getTestScheduler().flush(); + expect(dialogRefSpy.close).toHaveBeenCalledTimes(1); + }); - expect(component.subscription?.closed).toBeTrue(); + expect(dialogRefSpy.close).toHaveBeenCalledTimes(1); }); - it("should unsubscribe from the subscription when the timeout is reached", () => { - page.submitName(); + it("should not process requests with no name", () => { + spyOn(asyncRequestMediator, "submitRequestEvent"); - expect(component.subscription).toBeTruthy(); - expect(component.subscription?.closed).toBeFalse(); + component.nameField.setValue(""); + component.joinGroup(); - jasmine.clock().tick(component.timeout + 1); + expect(asyncRequestMediator.submitRequestEvent).toHaveBeenCalledTimes(0); - expect(component.subscription?.closed).toBeTrue(); + expect(component.nameField.hasError("required")).toBeTrue(); }); - it("should not trigger the timeout if a response is received before the timeout", () => { - page.submitName(); + it("should not process requests with a name that has only spaces", () => { + spyOn(asyncRequestMediator, "submitRequestEvent"); - getTestScheduler().flush(); + component.nameField.setValue(" "); + component.joinGroup(); - jasmine.clock().tick(component.timeout + 1); + expect(asyncRequestMediator.submitRequestEvent).toHaveBeenCalledTimes(0); - expect(component.errorJoiningGroup).toBeFalse(); + expect(dialogRefSpy.close.calls.count()).toBe(0); + expect(component.nameField.hasError("required")).toBeTrue(); }); it("should set the loading state to true when awaiting a response", () => { page.submitName(); fixture.detectChanges(); - expect(page.isLoadingVisible).toBeTrue(); - - expect(component.loading).toBeTrue(); - }); - - it("should set the loading state to false when timeout is reached", () => { - page.submitName(); - - fixture.detectChanges(); - expect(component.loading).toBeTrue(); expect(page.isLoadingVisible).toBeTrue(); - - getTestScheduler().flush(); - - jasmine.clock().tick(component.timeout + 1); - fixture.detectChanges(); - - expect(component.loading).toBeFalse(); - expect(page.isLoadingVisible).toBeFalse(); }); - it("should set the loading state to false when a response is received", () => { - page.submitName(); + it("should set the loading state to false when the request completes", () => { + testScheduler.run(({ cold, flush }) => { + spyOn(asyncRequestMediator, "submitRequestEvent").and.returnValue( + cold("|"), + ); - fixture.detectChanges(); + page.submitName(); + fixture.detectChanges(); - expect(component.loading).toBeTrue(); - expect(page.isLoadingVisible).toBeTrue(); + expect(component.loading).toBeTrue(); + expect(page.isLoadingVisible).toBeTrue(); - getTestScheduler().flush(); - fixture.detectChanges(); + flush(); - expect(component.loading).toBeFalse(); - expect(page.isLoadingVisible).toBeFalse(); - }); - }); + fixture.detectChanges(); - describe("error state", () => { - it("should not show errors in a normal state", () => { - fixture.detectChanges(); - expect(page.isErrorVisible).toBeFalse(); - }); - - it("should show an error if loading time exceeds timeout", async () => { - component.nameField.setValue("Test User"); - fixture.detectChanges(); - - page.submitName(); - fixture.detectChanges(); - - expect(component.loading).toBeTrue(); - expect(page.isLoadingVisible).toBeTrue(); - - jasmine.clock().tick(component.timeout + 1); - fixture.detectChanges(); - - expect(component.loading).toBeFalse(); - expect(page.isLoadingVisible).toBeFalse(); - expect(page.isErrorVisible).toBeTrue(); - expect(page.isServerUnavailableErrorVisible).toBeTrue(); - }); - - it("should show an error if the private update stream is not ready", () => { - component.nameField.setValue("Test User"); - fixture.detectChanges(); - - Object.defineProperty( - rsocketPrivateUpdateStreamServiceSpy, - "isPrivateUpdatesStreamReady", - { - get: () => false, - }, - ); - page.submitName(); - fixture.detectChanges(); - - expect(component.loading).toBeFalse(); - expect(component.isPrivateUpdateStreamReady).toBeFalse(); - expect(page.isErrorVisible).toBeTrue(); - expect(page.isStreamErrorVisible).toBeTrue(); + expect(component.loading).toBeFalse(); + expect(page.isLoadingVisible).toBeFalse(); + }); }); }); }); @@ -337,14 +153,6 @@ class Page { this._element = fixture.nativeElement; } - get isErrorVisible(): boolean { - return ( - this.isNameInputErrorVisible || - this.isStreamErrorVisible || - this.isServerUnavailableErrorVisible - ); - } - public submitName() { const submitButton = this._element.querySelector( "[data-test='join-group-button']", @@ -364,25 +172,9 @@ class Page { submitButton.click(); } - get isNameInputErrorVisible(): boolean { - const errorElement = this._element.querySelector( - "[data-test='name-input-error']", - ); - - return errorElement !== null; - } - - get isStreamErrorVisible(): boolean { - const errorElement = this._element.querySelector( - "[data-test='stream-error']", - ); - - return errorElement !== null; - } - - get isServerUnavailableErrorVisible(): boolean { + get isMemberNameRequiredErrorVisible(): boolean { const errorElement = this._element.querySelector( - "[data-test='server-unavailable-error']", + "[data-test='member-name-required-error']", ); return errorElement !== null; diff --git a/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.ts b/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.ts index 3e2271f..e8ba98f 100644 --- a/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.ts +++ b/src/app/groups/dialogs/groupInputNameDialog/groupInputNameDialog.component.ts @@ -16,15 +16,13 @@ import { MatButtonModule } from "@angular/material/button"; import { MatInputModule } from "@angular/material/input"; import { MatFormFieldModule } from "@angular/material/form-field"; import { AppMediaBreakpointDirective } from "../../../shared/directives/attr.breakpoint"; -import { RsocketRequestsService } from "../../../services/network/rsocket/requests/rsocketRequests.service"; -import { RsocketPrivateUpdateStreamService } from "../../../services/network/rsocket/streams/rsocketPrivateUpdateStream.service"; import { GroupModel } from "../../../model/group.model"; import { Subscription } from "rxjs"; -import { EventTypeEnum } from "../../../model/enums/eventType.enum"; -import { EventStatusEnum } from "../../../model/enums/eventStatus.enum"; -import { PrivateEventModel } from "../../../model/privateEvent.model"; import { UserService } from "../../../services/user/user.service"; import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { v4 as uuidv4 } from "uuid"; +import { AsynchronousRequestMediator } from "../../../services/notifications/asynchronousRequest.mediator"; +import { GroupJoinRequestEvent } from "../../../model/requestevent/GroupJoinRequestEvent"; @Component({ selector: "app-group-input-name-modal", @@ -47,20 +45,14 @@ export class GroupInputNameDialogComponent { nameField: FormControl = new FormControl("", { validators: [Validators.required], }); - - isPrivateUpdateStreamReady: boolean = true; - errorJoiningGroup: boolean = false; loading: boolean = false; - timeout: number = 5000; - timeoutId: any; subscription: Subscription | null = null; constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public group: GroupModel, - private readonly rsocketRequestsService: RsocketRequestsService, - private readonly rsocketPrivateUpdateStreamService: RsocketPrivateUpdateStreamService, public readonly userService: UserService, + private readonly asyncRequestMediator: AsynchronousRequestMediator, private formBuilder: FormBuilder, ) { this.myFormGroup = this.formBuilder.group({ @@ -77,48 +69,23 @@ export class GroupInputNameDialogComponent { if (this.nameField.invalid) return; - this.isPrivateUpdateStreamReady = - this.rsocketPrivateUpdateStreamService.isPrivateUpdatesStreamReady; - - if (this.isPrivateUpdateStreamReady) { - this.loading = true; - this.errorJoiningGroup = false; - this.subscription = - this.rsocketPrivateUpdateStreamService.privateUpdatesStream$.subscribe( - (privateEvent) => { - this.handleJoinGroupResponse(privateEvent); - }, - ); + const joinRequest = new GroupJoinRequestEvent( + uuidv4(), + this.group.id, + this.userService.uuid, + new Date().toISOString(), + this.nameField.value, + ); - this.rsocketRequestsService.sendJoinRequest( - this.nameField.value, - this.group.id, - this.userService.uuid, - ); + this.loading = true; - this.timeoutId = setTimeout(() => { - if (this.loading) { + this.asyncRequestMediator + .submitRequestEvent(joinRequest, "groups.join", "groups.updates.user") + .subscribe({ + complete: () => { this.loading = false; - this.errorJoiningGroup = true; - this.subscription?.unsubscribe(); - } - }, this.timeout); - } - } - - private handleJoinGroupResponse(privateEvent: PrivateEventModel) { - if ( - privateEvent.eventType === EventTypeEnum.MEMBER_JOINED && - privateEvent.aggregateId === this.group.id - ) { - this.loading = false; - this.subscription?.unsubscribe(); - clearTimeout(this.timeoutId); - if (privateEvent.eventStatus === EventStatusEnum.SUCCESSFUL) { - this.dialogRef.close(); - } else { - this.errorJoiningGroup = true; - } - } + this.dialogRef.close(); + }, + }); } } diff --git a/src/app/groups/groupBoard/groupBoard.component.html b/src/app/groups/groupBoard/groupBoard.component.html index 0591649..28da3eb 100644 --- a/src/app/groups/groupBoard/groupBoard.component.html +++ b/src/app/groups/groupBoard/groupBoard.component.html @@ -1,35 +1,42 @@ @if (!isGroupsSynced && isGroupsLoaded) { - -} @switch (componentState) {@case (StatesEnum.LOADING) { - -} @case (StatesEnum.READY) { - -} @case (StatesEnum.HTTP_INTERNAL_SERVER_ERROR) { - -} } + +} +@switch (componentState) { + @case (ComponentStatesEnum.LOADING) { + + } + @case (ComponentStatesEnum.READY) { + + } + @case (ComponentStatesEnum.RETRYING) { + + } +} diff --git a/src/app/groups/groupBoard/groupBoard.component.spec.ts b/src/app/groups/groupBoard/groupBoard.component.spec.ts index d8bfe26..d6992ff 100644 --- a/src/app/groups/groupBoard/groupBoard.component.spec.ts +++ b/src/app/groups/groupBoard/groupBoard.component.spec.ts @@ -1,7 +1,3 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GroupBoardComponent } from "./groupBoard.component"; -import { trigger } from "@angular/animations"; -import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Component, EventEmitter, @@ -9,22 +5,16 @@ import { Output, QueryList, } from "@angular/core"; -import { FlipService } from "../../services/miscellaneous/flip.service"; -import { StateTransitionService } from "../../services/miscellaneous/stateTransition.service"; -import { cold, getTestScheduler } from "jasmine-marbles"; -import { GroupManagerService } from "../services/groupManager.service"; -import { HttpService } from "../../services/network/http.service"; -import { UserService } from "../../services/user/user.service"; -import { BehaviorSubject, NEVER, of, Subject } from "rxjs"; -import { AbstractRetryService } from "../../services/retry/abstractRetry.service"; -import { ConfigService } from "../../config/config.service"; import { GroupCardComponent } from "../groupCard/groupCard.component"; import { GroupModel } from "../../model/group.model"; -import { StatesEnum } from "../../model/enums/states.enum"; -import { PublicEventModel } from "../../model/publicEvent.model"; -import { EventTypeEnum } from "../../model/enums/eventType.enum"; -import { RsocketPublicUpdateStreamService } from "../../services/network/rsocket/streams/rsocketPublicUpdateStream.service"; -import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; +import { GroupBoardComponent } from "./groupBoard.component"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { GroupManagerService } from "../services/groupManager.service"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { ConfigService } from "../../config/config.service"; +import { trigger } from "@angular/animations"; +import { StateEnum } from "src/app/services/state/StateEnum"; +import { of } from "rxjs"; @Component({ selector: "app-sync-banner", @@ -80,133 +70,20 @@ class GroupCardsStubComponent { isGroupsSynced = false; } -const mockGroups: GroupModel[] = [ - { - id: 1, - title: "Group 1", - description: "Group 1 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: Date.now().toString(), - lastModifiedBy: "Test User 1", - createdDate: Date.now().toString(), - createdBy: "Test User 1", - version: 1, - members: [], - }, - { - id: 2, - title: "Group 2", - description: "Group 2 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: Date.now().toString(), - lastModifiedBy: "Test User 2", - createdDate: Date.now().toString(), - createdBy: "Test User 2", - version: 1, - members: [], - }, -]; - describe("GroupBoardComponent", () => { let fixture: ComponentFixture; let component: GroupBoardComponent; let page: Page; - let flipServiceStub: jasmine.SpyObj; - let stateTransitionServiceStub: jasmine.SpyObj; - let groupManagerServiceStub: jasmine.SpyObj; - let httpServiceStub: jasmine.SpyObj; - let rsocketPublicUpdateStreamServiceSpy: jasmine.SpyObj; - let retryDefaultServiceStub: jasmine.SpyObj; - - beforeEach(async () => { - flipServiceStub = jasmine.createSpyObj("FlipService", ["animate"]); - flipServiceStub.animate.and.callFake(() => {}); - - stateTransitionServiceStub = jasmine.createSpyObj( - "StateTransitionService", - ["transitionTo", "transitionWithQueuedDelayTo"], - ); - stateTransitionServiceStub.transitionTo.and.callFake(() => {}); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - () => {}, - ); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => NEVER, - configurable: true, - }); - - groupManagerServiceStub = jasmine.createSpyObj("GroupManagerService", [ - "handleUpdates", - "triggerSort", - ]); - groupManagerServiceStub.handleUpdates.and.callFake(() => {}); - groupManagerServiceStub.triggerSort.and.callFake(() => {}); - Object.defineProperty(groupManagerServiceStub, "groupUpdateActions$", { - get: () => NEVER, - configurable: true, - }); - const groupsManagerObservable = new BehaviorSubject([]); - Object.defineProperty(groupManagerServiceStub, "groups", { - value: groupsManagerObservable, - writable: true, - }); - Object.defineProperty(groupManagerServiceStub, "groups$", { - get: () => groupsManagerObservable.asObservable(), - configurable: true, - }); - - httpServiceStub = jasmine.createSpyObj("HttpService", ["getGroups"]); - httpServiceStub.getGroups.and.callFake(() => of([])); - - rsocketPublicUpdateStreamServiceSpy = jasmine.createSpyObj( - "RsocketPublicUpdateStreamService", - ["initializePublicUpdateStream"], - ); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => NEVER, - configurable: true, - }, - ); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "publicUpdatesStream$", - { - get: () => NEVER, - configurable: true, - }, - ); - - retryDefaultServiceStub = jasmine.createSpyObj("RetryDefaultService", [ - "addRetryLogic", - ]); - retryDefaultServiceStub.addRetryLogic.and.callFake((arg: any) => arg); - Object.defineProperty(retryDefaultServiceStub, "nextRetryInSeconds$", { - get: () => NEVER, - configurable: true, - }); + let groupManagerService: GroupManagerService; - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, GroupBoardComponent], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], providers: [ - { provide: FlipService, useValue: flipServiceStub }, - { - provide: StateTransitionService, - useValue: stateTransitionServiceStub, - }, - { provide: GroupManagerService, useValue: groupManagerServiceStub }, - { provide: HttpService, useValue: httpServiceStub }, - { provide: UserService, useValue: {} }, { - provide: RsocketPublicUpdateStreamService, - useValue: rsocketPublicUpdateStreamServiceSpy, + provide: ConfigService, + useValue: {}, }, - { provide: AbstractRetryService, useValue: retryDefaultServiceStub }, - { provide: ConfigService, useValue: {} }, ], }) .overrideComponent(GroupBoardComponent, { @@ -225,746 +102,223 @@ describe("GroupBoardComponent", () => { fixture = TestBed.createComponent(GroupBoardComponent); component = fixture.componentInstance; page = new Page(fixture); - }); - it("creates the component", () => { - expect(component).toBeTruthy(); + groupManagerService = TestBed.inject(GroupManagerService); }); - function runNonReadyLoadGroups() { - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), - }); - stateTransitionServiceStub.transitionTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - - fixture.detectChanges(); - getTestScheduler().flush(); - } - - describe("initialization", () => { - beforeEach(() => { - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => of(true), - }, - ); + describe("initial state", () => { + it("should create the component in the INITIALIZING state", () => { + expect(component.componentState).toBe(StateEnum.INITIALIZING); }); - it("should load groups", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - fixture.detectChanges(); // first change detection triggers ngOnInit - getTestScheduler().flush(); // flush observables - - expect(component.groups).toEqual(mockGroups); - }); - - it("should set the component state to loading by default", () => { - expect(component.componentState).toBe(StatesEnum.LOADING); - }); - - it("should set the component state to loading when loading groups", () => { - const groupsObservable = cold("-"); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - runNonReadyLoadGroups(); - - expect(stateTransitionServiceStub.transitionTo).toHaveBeenCalled(); - expect(component.componentState).toBe(StatesEnum.LOADING); - }); - - it("should set the component state to error when loading groups fail", () => { - const groupsObservable = cold("#"); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - runNonReadyLoadGroups(); - - expect(stateTransitionServiceStub.transitionTo).toHaveBeenCalled(); - expect(component.componentState).toBe( - StatesEnum.HTTP_INTERNAL_SERVER_ERROR, - ); - }); - - it("should set the component state to ready when loading groups succeed", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), - }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); + it("should set the the groups loaded flag to true if the component state from Group Manager Service is ready", () => { + spyOnProperty( + groupManagerService, + "componentState", + "get", + ).and.returnValue(StateEnum.READY); - fixture.detectChanges(); - getTestScheduler().flush(); + component = + TestBed.createComponent(GroupBoardComponent).componentInstance; - expect(stateTransitionServiceStub.transitionTo).toHaveBeenCalled(); - expect(component.componentState).toBe(StatesEnum.READY); + expect(component.isGroupsLoaded).toBeTrue(); }); - it("should set the sync state to true if public update stream is connected", () => { - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, + it("should set the synced text and groups synced flags to true if the group state from Group Manager Service is ready", () => { + spyOnProperty(groupManagerService, "groupState").and.returnValue( + StateEnum.READY, ); - fixture.detectChanges(); - - subject.next(true); - - getTestScheduler().flush(); + component = + TestBed.createComponent(GroupBoardComponent).componentInstance; - expect(component.isGroupsSynced).toBeTrue(); expect(component.syncedText).toBeTrue(); - }); - - it("should set the sync state to false if public update stream is not connected", () => { - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); - - fixture.detectChanges(); - - subject.next(false); - - getTestScheduler().flush(); - - expect(component.isGroupsSynced).toBeFalse(); - expect(component.syncedText).toBeFalse(); - }); - }); - - it("should not load groups when the sync state is ready but the groups failed to load", () => { - const groupsObservable = cold("#"); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); - - fixture.detectChanges(); - - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(1); - - subject.next(true); - - getTestScheduler().flush(); - - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(1); - }); - - describe("#loadGroups", () => { - it("should load groups to the component", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - component.loadGroups(); - getTestScheduler().flush(); - - expect(httpServiceStub.getGroups).toHaveBeenCalled(); - expect(component.groups).toEqual(mockGroups); - }); - - it("should load groups to the group manager service", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - component.loadGroups(); - getTestScheduler().flush(); - - expect(httpServiceStub.getGroups).toHaveBeenCalled(); - expect(groupManagerServiceStub.groups.getValue()).toEqual(mockGroups); - }); - - it("should set the component state to loading when loading groups", () => { - const groupsObservable = cold("-"); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - component.loadGroups(); - getTestScheduler().flush(); - - expect(stateTransitionServiceStub.transitionTo).toHaveBeenCalled(); - expect(component.componentState).toBe(StatesEnum.LOADING); - }); - - it("should set the component state to ready when loading groups succeed", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - component.loadGroups(); - getTestScheduler().flush(); - - expect( - stateTransitionServiceStub.transitionWithQueuedDelayTo, - ).toHaveBeenCalledWith(StatesEnum.READY, jasmine.any(Number)); - }); - - it("should set the component state to error when loading groups fail", () => { - const groupsObservable = cold("#"); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - component.loadGroups(); - getTestScheduler().flush(); - - expect(stateTransitionServiceStub.transitionTo).toHaveBeenCalledWith( - StatesEnum.HTTP_INTERNAL_SERVER_ERROR, - ); + expect(component.isGroupsSynced).toBeTrue(); }); }); - describe("#transitionState", () => { - it(`transitions to the ${StatesEnum.HTTP_INTERNAL_SERVER_ERROR} state when the argument is ${StatesEnum.HTTP_INTERNAL_SERVER_ERROR}`, () => { - component.transitionState(StatesEnum.HTTP_INTERNAL_SERVER_ERROR); - expect(stateTransitionServiceStub.transitionTo).toHaveBeenCalledWith( - StatesEnum.HTTP_INTERNAL_SERVER_ERROR, - ); - }); - - it(`transitions with a delay for each to the ${StatesEnum.NEUTRAL} followed by ${StatesEnum.READY} state when the argument is ${StatesEnum.READY}`, () => { - component.transitionState(StatesEnum.READY); - expect( - stateTransitionServiceStub.transitionWithQueuedDelayTo, - ).toHaveBeenCalledWith(StatesEnum.NEUTRAL, jasmine.any(Number)); - expect( - stateTransitionServiceStub.transitionWithQueuedDelayTo, - ).toHaveBeenCalledWith(StatesEnum.READY, jasmine.any(Number)); - }); - - it(`transitions with a delay for each to the ${StatesEnum.NEUTRAL} followed by ${StatesEnum.LOADING} state when the argument is ${StatesEnum.LOADING} and groups have been loaded`, () => { - component.isGroupsLoaded = true; - component.transitionState(StatesEnum.LOADING); - expect( - stateTransitionServiceStub.transitionWithQueuedDelayTo, - ).toHaveBeenCalledWith(StatesEnum.NEUTRAL, jasmine.any(Number)); - expect( - stateTransitionServiceStub.transitionWithQueuedDelayTo, - ).toHaveBeenCalledWith(StatesEnum.LOADING, jasmine.any(Number)); - }); + describe("ngOnInit", () => { + describe("initializing timeout", () => { + beforeEach(() => { + spyOn(groupManagerService, "subscribeToGroupsStream"); + }); - it(`transitions without delay to the ${StatesEnum.LOADING} state when the argument is ${StatesEnum.LOADING} and groups have not been loaded`, () => { - component.isGroupsLoaded = false; - component.transitionState(StatesEnum.LOADING); - expect(stateTransitionServiceStub.transitionTo).toHaveBeenCalledWith( - StatesEnum.LOADING, - ); - }); + afterEach(() => { + jasmine.clock().uninstall(); + }); - it(`sets the groups loaded flag to false when the argument is ${StatesEnum.LOADING}`, () => { - component.isGroupsLoaded = true; - component.transitionState(StatesEnum.LOADING); - expect(component.isGroupsLoaded).toBeFalse(); - }); + it("should set the component state to LOADING if the component state is still initializing after the cutoff time", () => { + jasmine.clock().install(); - it(`sets the groups loaded flag to false when the argument is ${StatesEnum.HTTP_INTERNAL_SERVER_ERROR}`, () => { - component.isGroupsLoaded = true; - component.transitionState(StatesEnum.HTTP_INTERNAL_SERVER_ERROR); - expect(component.isGroupsLoaded).toBeFalse(); - }); + fixture.detectChanges(); - it(`sets the groups loaded flag to true when the argument is ${StatesEnum.READY}`, () => { - component.isGroupsLoaded = false; - component.transitionState(StatesEnum.READY); - expect(component.isGroupsLoaded).toBeTrue(); - }); - - it(`does not transition when the argument is ${StatesEnum.NEUTRAL}`, () => { - component.transitionState(StatesEnum.NEUTRAL); - expect(stateTransitionServiceStub.transitionTo).not.toHaveBeenCalled(); - expect( - stateTransitionServiceStub.transitionWithQueuedDelayTo, - ).not.toHaveBeenCalled(); - }); - }); + jasmine.clock().tick(component.initializingTimeout); - describe("loading state", () => { - it("should show the loading component when loading", () => { - httpServiceStub.getGroups.and.callFake(() => NEVER); - fixture.detectChanges(); - expect(page.isActiveLoadingComponentVisible).toBeTrue(); - }); - }); - - describe("ready state", () => { - beforeEach(() => { - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => of(true), - }, - ); - }); - - it("render the group cards container", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), + expect(component.componentState).toBe(StateEnum.LOADING); }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - fixture.detectChanges(); - getTestScheduler().flush(); - fixture.detectChanges(); + it("should not set the component state to LOADING if the component state is ready before the cutoff time", () => { + spyOnProperty( + groupManagerService, + "componentState$", + "get", + ).and.returnValue(of(StateEnum.READY)); - expect( - stateTransitionServiceStub.transitionWithQueuedDelayTo, - ).toHaveBeenCalled(); - expect(page.isCardsComponentVisible).toBeTrue(); - }); - }); + fixture.detectChanges(); - describe("failure states", () => { - beforeEach(() => { - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => of(true), - }, - ); + expect(component.componentState).toBe(StateEnum.READY); + }); }); - it("should show the loading failed state when loading fails", () => { - const groupsObservable = cold("#"); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); + describe("initializing component state stream", () => { + it("sets the component state to the component states streamed by group manager service", () => { + spyOnProperty( + groupManagerService, + "componentState$", + "get", + ).and.returnValue( + of(StateEnum.INITIALIZING, StateEnum.RETRYING, StateEnum.READY), + ); - runNonReadyLoadGroups(); + fixture.detectChanges(); - fixture.detectChanges(); - - expect(page.isInactiveLoadingComponentVisible).toBeTrue(); - }); - - it("should show the sync failed state when syncing fails and loading succeeds", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), + expect(component.componentState).toBe(StateEnum.READY); }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); - - fixture.detectChanges(); - getTestScheduler().flush(); - - subject.next(false); - fixture.detectChanges(); + it("should set the groups loaded flag to true if the component state is ready", () => { + expect(component.isGroupsLoaded).toBeFalse(); - getTestScheduler().flush(); - fixture.detectChanges(); - - console.debug(component); - expect(page.isSyncBannerComponentVisible).toBeTrue(); - }); + spyOnProperty( + groupManagerService, + "componentState$", + "get", + ).and.returnValue(of(StateEnum.READY)); - it("should not show the sync failed state when both loading and sync fails", () => { - const groupsObservable = cold("#"); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); + fixture.detectChanges(); - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), + expect(component.isGroupsLoaded).toBeTrue(); }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); + }); - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); + describe("initializing group state stream", () => { + it("should set the synced text and groups synced flags to true if the group state is ready", () => { + component.syncedText = false; + component.isGroupsSynced = false; - fixture.detectChanges(); - getTestScheduler().flush(); + spyOnProperty( + groupManagerService, + "groupState$", + "get", + ).and.returnValue(of(StateEnum.READY)); - subject.next(false); - fixture.detectChanges(); + fixture.detectChanges(); - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(1); - expect(page.isSyncBannerComponentVisible).toBeFalse(); - }); - - it("should not show the loading failed state when loading succeeds", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), + expect(component.syncedText).toBeTrue(); + expect(component.isGroupsSynced).toBeTrue(); }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - fixture.detectChanges(); - getTestScheduler().flush(); + it("should set the synced text and groups synced flags to false if the group state is not ready", () => { + component.syncedText = true; + component.isGroupsSynced = true; - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(1); - expect(component.componentState).toBe(StatesEnum.READY); - expect(page.isInactiveLoadingComponentVisible).toBeFalse(); - }); + spyOnProperty( + groupManagerService, + "groupState$", + "get", + ).and.returnValue(of(StateEnum.LOADING)); - it("should not show the sync failed state when syncing succeeds", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); + fixture.detectChanges(); - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), + expect(component.syncedText).toBeFalse(); + expect(component.isGroupsSynced).toBeFalse(); }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); - - fixture.detectChanges(); - getTestScheduler().flush(); + it("should update the next retry if the group state is retrying and show the retrying component", () => { + spyOnProperty( + groupManagerService, + "groupState$", + "get", + ).and.returnValue(of(StateEnum.RETRYING)); - subject.next(true); - fixture.detectChanges(); + spyOnProperty( + groupManagerService, + "streamRetryTime", + "get", + ).and.returnValue(of(3, 2, 1)); - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(2); - expect(page.isSyncBannerComponentVisible).toBeFalse(); - }); + fixture.detectChanges(); - it("should show the sync failed state if syncing fails after loading succeeds", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), + expect(component.nextRetry).toBe(1); }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); + }); + }); + describe("component state", () => { + it("should not show any components if the component state is initializing", () => { fixture.detectChanges(); - - subject.next(true); - getTestScheduler().flush(); + component.componentState = StateEnum.INITIALIZING; fixture.detectChanges(); - subject.next(false); - fixture.detectChanges(); - - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(1); - expect(page.isSyncBannerComponentVisible).toBeTrue(); + expect(page.isActiveLoadingComponentVisible).toBeFalse(); + expect(page.isInactiveLoadingComponentVisible).toBeFalse(); + expect(page.isCardsComponentVisible).toBeFalse(); }); - it("should trigger a new load when sync transitions from failed to success", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), - }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); - + it("should show the active loading component if the component state is loading", () => { fixture.detectChanges(); - getTestScheduler().flush(); + component.componentState = StateEnum.LOADING; fixture.detectChanges(); - subject.next(false); - subject.next(true); - - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(2); + expect(page.isActiveLoadingComponentVisible).toBeTrue(); + expect(page.isInactiveLoadingComponentVisible).toBeFalse(); + expect(page.isCardsComponentVisible).toBeFalse(); }); - it("should not show the sync failed state if syncing transitions from failed to success after loading succeeds", () => { - const groupsObservable = cold("a|", { a: mockGroups }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), - }); - stateTransitionServiceStub.transitionWithQueuedDelayTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); - + it("should show the inactive loading component if the component state is retrying", () => { fixture.detectChanges(); - - subject.next(true); - getTestScheduler().flush(); + component.componentState = StateEnum.RETRYING; fixture.detectChanges(); - subject.next(false); - fixture.detectChanges(); - - subject.next(true); - fixture.detectChanges(); - - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(2); - expect(page.isSyncBannerComponentVisible).toBeFalse(); + expect(page.isActiveLoadingComponentVisible).toBeFalse(); + expect(page.isInactiveLoadingComponentVisible).toBeTrue(); + expect(page.isCardsComponentVisible).toBeFalse(); }); - it("should show the sync failed state when loading succeeds, syncing fails, and there are no groups", () => { - const groupsObservable = cold("a|", { a: [] }); - httpServiceStub.getGroups.and.callFake(() => groupsObservable); - - const subjectState = new Subject(); - Object.defineProperty(stateTransitionServiceStub, "currentState$", { - get: () => subjectState.asObservable(), - }); - stateTransitionServiceStub.transitionTo.and.callFake( - (state: StatesEnum) => { - subjectState.next(state); - }, - ); - - const subject = new Subject(); - Object.defineProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - { - get: () => subject.asObservable(), - }, - ); - + it("should show the cards component if the component state is ready", () => { fixture.detectChanges(); - getTestScheduler().flush(); - - subject.next(false); + component.componentState = StateEnum.READY; fixture.detectChanges(); - getTestScheduler().flush(); - fixture.detectChanges(); - - expect(httpServiceStub.getGroups).toHaveBeenCalledTimes(1); - expect(page.isSyncBannerComponentVisible).toBeTrue(); + expect(page.isActiveLoadingComponentVisible).toBeFalse(); + expect(page.isInactiveLoadingComponentVisible).toBeFalse(); + expect(page.isCardsComponentVisible).toBeTrue(); }); }); - describe("groups", () => { - it("should pass an event handler to for the public updates stream", () => { - const mockEvent = {} as PublicEventModel; - const mockReadyObservable = cold("a", { a: true }); - - spyOnProperty( - rsocketPublicUpdateStreamServiceSpy, - "isPublicUpdatesStreamReady$", - "get", - ).and.returnValue(mockReadyObservable); - spyOnProperty( - rsocketPublicUpdateStreamServiceSpy, - "publicUpdatesStream$", - "get", - ).and.returnValue(cold("a", { a: mockEvent })); - - groupManagerServiceStub.handleUpdates.and.callFake(() => {}); - fixture.detectChanges(); - getTestScheduler().flush(); - expect(groupManagerServiceStub.handleUpdates).toHaveBeenCalledWith( - mockEvent, - ); - }); - - it("should apply updates from the group manager service to the groups when component is ready", () => { - component.componentState = StatesEnum.READY; - - const groupUpdateActions = [ - { - eventType: EventTypeEnum.GROUP_CREATED, - updateFunction: jasmine.createSpy("updateFunction1"), - groupId: "1", - }, - { - eventType: EventTypeEnum.GROUP_DISBANDED, - updateFunction: jasmine.createSpy("updateFunction2"), - groupId: "2", - }, - { - eventType: EventTypeEnum.GROUP_UPDATED, - updateFunction: jasmine.createSpy("updateFunction3"), - groupId: "3", - }, - ]; - - const groupUpdateActionsObservable = cold("-a-b-c-", { - a: groupUpdateActions[0], - b: groupUpdateActions[1], - c: groupUpdateActions[2], - }); - - Object.defineProperty(groupManagerServiceStub, "groupUpdateActions$", { - get: () => groupUpdateActionsObservable, - }); - - flipServiceStub.animate.and.callFake((updateFunction: () => any) => - updateFunction(), + describe("sync banner state", () => { + it("should show the sync banner groups are loaded and the group state is not ready", () => { + spyOnProperty(groupManagerService, "groupState", "get").and.returnValue( + StateEnum.LOADING, ); fixture.detectChanges(); + component.isGroupsLoaded = true; fixture.detectChanges(); - getTestScheduler().flush(); - expect(groupUpdateActions[0].updateFunction).toHaveBeenCalled(); - expect(groupUpdateActions[1].updateFunction).toHaveBeenCalled(); - expect(groupUpdateActions[2].updateFunction).toHaveBeenCalled(); + expect(page.isSyncBannerComponentVisible).toBeTrue(); }); - it("should not apply updates from the group manager service to the groups when component is not ready", () => { - component.componentState = StatesEnum.LOADING; - - const groupUpdateActions = [ - { - eventType: EventTypeEnum.GROUP_CREATED, - updateFunction: jasmine.createSpy("updateFunction1"), - groupId: "1", - }, - ]; - - const groupUpdateActionsObservable = cold("-a-", { - a: groupUpdateActions[0], - }); - - Object.defineProperty(groupManagerServiceStub, "groupUpdateActions$", { - get: () => groupUpdateActionsObservable, - }); - - flipServiceStub.animate.and.callFake((updateFunction: () => any) => - updateFunction(), + it("should not show the sync banner component if the group state is ready", () => { + spyOnProperty(groupManagerService, "groupState", "get").and.returnValue( + StateEnum.READY, ); - fixture.detectChanges(); - getTestScheduler().flush(); - - expect(groupUpdateActions[0].updateFunction).not.toHaveBeenCalled(); - }); - - it("should perform group disbanded updates by passing the groups id", () => { - component.componentState = StatesEnum.READY; - - const groupUpdateActions = [ - { - eventType: EventTypeEnum.GROUP_DISBANDED, - updateFunction: jasmine.createSpy("updateFunction1"), - groupId: "1", - }, - ]; - const groupUpdateActionsObservable = cold("-a-", { - a: groupUpdateActions[0], - }); - - Object.defineProperty(groupManagerServiceStub, "groupUpdateActions$", { - get: () => groupUpdateActionsObservable, - }); - - flipServiceStub.animate.and.callFake((updateFunction: () => any) => - updateFunction(), - ); + component.componentState = StateEnum.READY; fixture.detectChanges(); - getTestScheduler().flush(); - expect(flipServiceStub.animate).toHaveBeenCalledOnceWith( - groupUpdateActions[0].updateFunction, - undefined, - groupUpdateActions[0].groupId, - ); - expect(groupUpdateActions[0].updateFunction).toHaveBeenCalled(); + expect(page.isSyncBannerComponentVisible).toBeFalse(); }); }); }); @@ -992,7 +346,7 @@ class Page { get isInactiveLoadingComponentVisible(): boolean { const element = this._element.querySelector( - "[data-test='loading-component__http-internal-server-error']", + "[data-test='loading-component__retrying']", ); return element !== null; } diff --git a/src/app/groups/groupBoard/groupBoard.component.ts b/src/app/groups/groupBoard/groupBoard.component.ts index dc9835f..017f386 100644 --- a/src/app/groups/groupBoard/groupBoard.component.ts +++ b/src/app/groups/groupBoard/groupBoard.component.ts @@ -3,202 +3,100 @@ import { Component, DestroyRef, OnInit, - QueryList, } from "@angular/core"; -import { GroupModel } from "../../model/group.model"; -import { EventTypeEnum } from "../../model/enums/eventType.enum"; -import { - FlipService, - ID_ATTRIBUTE_TOKEN, -} from "../../services/miscellaneous/flip.service"; -import { StatesEnum } from "../../model/enums/states.enum"; import { GroupBoardAnimation } from "./groupBoard.animation"; -import { GroupCardComponent } from "../groupCard/groupCard.component"; -import { ConfigService } from "../../config/config.service"; -import { StateTransitionService } from "../../services/miscellaneous/stateTransition.service"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { GroupManagerService } from "../services/groupManager.service"; -import { HttpService } from "../../services/network/http.service"; -import { UserService } from "../../services/user/user.service"; -import { AbstractRetryService } from "../../services/retry/abstractRetry.service"; -import { Subscription } from "rxjs"; -import { RsocketPublicUpdateStreamService } from "../../services/network/rsocket/streams/rsocketPublicUpdateStream.service"; +import { delay, of, Subscription } from "rxjs"; import { GroupCardsComponent } from "../groupCards/groupCards.component"; import { LoadingComponent } from "../../shared/loading/loading.component"; import { SyncBannerComponent } from "../../shared/syncBanner/syncBanner.component"; -import { RetryDefaultService } from "../../services/retry/retryDefault.service"; +import { StateEnum } from "src/app/services/state/StateEnum"; @Component({ selector: "app-group-board", templateUrl: "groupBoard.component.html", animations: [GroupBoardAnimation], - providers: [ - FlipService, - { provide: ID_ATTRIBUTE_TOKEN, useValue: "data-group-id" }, - StateTransitionService, - { provide: AbstractRetryService, useClass: RetryDefaultService }, - ], standalone: true, imports: [SyncBannerComponent, LoadingComponent, GroupCardsComponent], }) export class GroupBoardComponent implements OnInit { - public componentState: StatesEnum = StatesEnum.LOADING; - public readonly StatesEnum = StatesEnum; public subscription: Subscription | null = null; - public groups: GroupModel[] = []; public nextRetry: number | null = null; public isGroupsLoaded = false; public isGroupsSynced = false; public syncedText = false; + public componentState: StateEnum = StateEnum.INITIALIZING; + protected readonly ComponentStatesEnum = StateEnum; + private readonly GROUPS_ROUTE = "groups.updates.all"; + public readonly initializingTimeout = 400; constructor( - private readonly changeDetectorRef: ChangeDetectorRef, + public readonly changeDetectorRef: ChangeDetectorRef, private readonly destroyRef: DestroyRef, - private readonly flipService: FlipService, - private readonly stateTransitionService: StateTransitionService, - private readonly groupManagerService: GroupManagerService, - private readonly httpService: HttpService, - private readonly userService: UserService, - private readonly rsocketPublicUpdateStreamService: RsocketPublicUpdateStreamService, - private readonly retryDefaultService: AbstractRetryService, - private readonly configService?: ConfigService, + public readonly groupManagerService: GroupManagerService, ) { - this.rsocketPublicUpdateStreamService.initializePublicUpdateStream( - this.userService.uuid, - ); + this.setInitialStates(); } - ngOnInit() { - this.rsocketPublicUpdateStreamService.isPublicUpdatesStreamReady$.subscribe( - (isReady) => { - console.debug("isReady", isReady); - const wasSynced = this.syncedText; - - this.syncedText = isReady; - this.changeDetectorRef.detectChanges(); - this.isGroupsSynced = isReady; - - if (!wasSynced && isReady && this.isGroupsLoaded) { - this.stateTransitionService.transitionTo(StatesEnum.NEUTRAL); - this.loadGroups(); - } - }, - ); + private setInitialStates() { + this.isGroupsLoaded = + this.groupManagerService.componentState === StateEnum.READY; + this.syncedText = this.groupManagerService.groupState === StateEnum.READY; + this.isGroupsSynced = this.syncedText; + } - this.rsocketPublicUpdateStreamService.publicUpdatesStream$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((event) => { - this.groupManagerService.handleUpdates(event); - console.debug("EVENT", event); - }); + ngOnInit(): void { + this.setLoadingIfInitializingTimeout(); - console.debug("nextRetry", this.retryDefaultService.nextRetryInSeconds$); - this.retryDefaultService.nextRetryInSeconds$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((nextRetry) => { - this.nextRetry = nextRetry; - }); + if (this.groupManagerService.currentGroupRoute !== this.GROUPS_ROUTE) { + this.groupManagerService.subscribeToGroupsStream(this.GROUPS_ROUTE); + } - this.stateTransitionService.currentState$ + this.groupManagerService.componentState$ .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((state) => { - console.debug("Transitioning to state: " + state); - this.componentState = state; + .subscribe((status) => { + this.componentState = status; + this.setGroupsLoadedIfReady(status); }); - this.groupManagerService.groupUpdateActions$ + this.groupManagerService.groupState$ .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((groupUpdate) => { - console.debug("Handling group update"); - if (this.componentState !== StatesEnum.READY) { - return; - } - this.changeDetectorRef.detectChanges(); - if (groupUpdate.eventType === EventTypeEnum.GROUP_DISBANDED) { - this.flipService.animate( - groupUpdate.updateFunction, - undefined, - groupUpdate.groupId.toString(), - ); - } else { - this.flipService.animate( - groupUpdate.updateFunction, - this.changeDetectorRef, - ); - } + .subscribe((status) => { + this.updateSyncBanner(status); + this.updateNextRetryIfRetrying(status); }); - - this.loadGroups(); } - get minimumLoadingTimeSeconds() { - return this.configService?.getGroupBoardLoadingDelaySeconds ?? 0; + private updateSyncBanner(status: StateEnum) { + this.syncedText = status === StateEnum.READY; + if (status === StateEnum.READY) this.changeDetectorRef.detectChanges(); + this.isGroupsSynced = status === StateEnum.READY; } - public loadGroups() { - this.subscription?.unsubscribe(); - this.nextRetry = null; - this.transitionState(StatesEnum.LOADING); - const username = this.userService.uuid; - const getGroupsWithRetry = this.retryDefaultService.addRetryLogic( - this.httpService.getGroups(username), - ); - this.subscription = getGroupsWithRetry.subscribe({ - next: (groups) => { - console.debug("GOT GROUPS", groups); - this.groupManagerService.groups.next(groups); - this.groupManagerService.groups$.subscribe( - (groups) => (this.groups = groups), - ); - this.transitionState(StatesEnum.READY); - }, - error: (error) => { - console.debug(error); - this.transitionState(StatesEnum.HTTP_INTERNAL_SERVER_ERROR); - }, - }); + private setGroupsLoadedIfReady(status: StateEnum) { + if (status === StateEnum.READY) { + this.isGroupsLoaded = true; + } } - public transitionState(state: StatesEnum) { - console.debug("state transition", this.stateTransitionService); - switch (state) { - case StatesEnum.HTTP_INTERNAL_SERVER_ERROR: - this.stateTransitionService.transitionTo( - StatesEnum.HTTP_INTERNAL_SERVER_ERROR, - ); - this.isGroupsLoaded = false; - break; - case StatesEnum.READY: - this.stateTransitionService.transitionWithQueuedDelayTo( - StatesEnum.NEUTRAL, - this.minimumLoadingTimeSeconds * 1000, - ); - this.stateTransitionService.transitionWithQueuedDelayTo( - StatesEnum.READY, - this.minimumLoadingTimeSeconds * 1000, - ); - this.isGroupsLoaded = true; - break; - case StatesEnum.LOADING: - if (!this.isGroupsLoaded) { - this.stateTransitionService.transitionTo(StatesEnum.LOADING); - } else { - this.stateTransitionService.transitionWithQueuedDelayTo( - StatesEnum.NEUTRAL, - this.minimumLoadingTimeSeconds * 1000, - ); - this.stateTransitionService.transitionWithQueuedDelayTo( - StatesEnum.LOADING, - this.minimumLoadingTimeSeconds * 1000, - ); - this.isGroupsLoaded = false; - break; - } + private updateNextRetryIfRetrying(status: StateEnum) { + if (status === StateEnum.RETRYING) { + console.debug("Retrying state detected, updating next retry time"); + this.groupManagerService.streamRetryTime?.subscribe((nextRetry) => { + this.nextRetry = nextRetry; + }); } } - public setCardComponents(components: QueryList) { - this.flipService.setComponents(components); - this.groupManagerService.triggerSort(); + private setLoadingIfInitializingTimeout() { + of(StateEnum.LOADING) + .pipe(delay(this.initializingTimeout)) + .subscribe((status) => { + if (this.componentState === StateEnum.INITIALIZING) { + console.debug("Initializing timeout, loading state triggerred"); + this.componentState = status; + } + }); } } diff --git a/src/app/groups/groupCard/groupCard.component.html b/src/app/groups/groupCard/groupCard.component.html index 90da953..4cd2217 100644 --- a/src/app/groups/groupCard/groupCard.component.html +++ b/src/app/groups/groupCard/groupCard.component.html @@ -10,13 +10,13 @@

{{ group.title }}

@if (userService.currentGroupId === group.id) { - person_check + person_check }
diff --git a/src/app/groups/groupCard/groupCard.component.spec.ts b/src/app/groups/groupCard/groupCard.component.spec.ts index f2aad1d..169076c 100644 --- a/src/app/groups/groupCard/groupCard.component.spec.ts +++ b/src/app/groups/groupCard/groupCard.component.spec.ts @@ -1,90 +1,47 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { GroupCardComponent } from "./groupCard.component"; -import { MatDialog, MatDialogConfig } from "@angular/material/dialog"; +import { + MatDialog, + MatDialogConfig, + MatDialogState, +} from "@angular/material/dialog"; import { GroupDetailsDialogComponent } from "../dialogs/groupDetailsDialog/groupDetailsDialog.component"; -import { Component } from "@angular/core"; import { GroupModel } from "../../model/group.model"; import { MemberModel } from "../../model/member.model"; import { UserService } from "../../services/user/user.service"; -import { MemberStatusEnum } from "../../model/enums/memberStatus.enum"; -import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; - -@Component({ - template: ``, - standalone: true, - imports: [GroupCardComponent], -}) -class TestHostComponent { - group: GroupModel = { - id: 1, - title: "Group 1", - description: "Group 1 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: Date.now().toString(), - lastModifiedBy: "Test User 1", - createdDate: Date.now().toString(), - createdBy: "Test User 1", - version: 1, - members: [ - new MemberModel( - 1, - "Test User 1", - 1, - MemberStatusEnum.ACTIVE, - Date.now().toString(), - null, - ), - new MemberModel( - 2, - "Test User 2", - 1, - MemberStatusEnum.ACTIVE, - Date.now().toString(), - null, - ), - new MemberModel( - 3, - "Test User 3", - 1, - MemberStatusEnum.ACTIVE, - Date.now().toString(), - null, - ), - ], - }; -} +import { ConfigService } from "../../config/config.service"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; describe("GroupCardComponent", () => { - let fixture: ComponentFixture; - let testHost: TestHostComponent; + let fixture: ComponentFixture; + let component: GroupCardComponent; let dialog: MatDialog; let page: GroupCardPage; - const userServiceStub: Partial = {}; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GroupCardComponent, TestHostComponent], - providers: [ - { provide: UserService, useValue: userServiceStub }, - { - provide: MatDialog, - useValue: { - open: () => {}, - }, - }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TestHostComponent); - page = new GroupCardPage(fixture); - testHost = fixture.componentInstance; - fixture.detectChanges(); + let userService: UserService; + + beforeEach(() => { + const members: Partial[] = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const group: Partial = { + id: 1, + title: "Group 1", + description: "Group 1 description", + maxGroupSize: 10, + members: members as MemberModel[], + }; + + TestBed.configureTestingModule({ + imports: [GroupCardComponent, NoopAnimationsModule], + providers: [{ provide: ConfigService, useValue: {} }], + }); + + fixture = TestBed.createComponent(GroupCardComponent); + component = fixture.componentInstance; + component.group = group as GroupModel; + userService = TestBed.inject(UserService); dialog = TestBed.inject(MatDialog); - }); + page = new GroupCardPage(fixture); - it("creates the component", () => { - expect(testHost).toBeTruthy(); + fixture.detectChanges(); }); it("has a group card", () => { @@ -116,7 +73,14 @@ describe("GroupCardComponent", () => { }); it("opens a dialog when clicked", () => { - const dialogOpenSpy = spyOn(dialog, "open"); + page.clickCard(); + fixture.detectChanges(); + + expect(dialog.openDialogs.length).toBe(1); + }); + + it("opens a group details dialog when clicked", () => { + const dialogOpenSpy = spyOn(dialog, "open").and.callThrough(); page.clickCard(); fixture.detectChanges(); @@ -128,24 +92,40 @@ describe("GroupCardComponent", () => { }); it("shows the 'your group' icon when the user is a member of the group", () => { - userServiceStub.currentGroupId = 1; + userService.setUserInGroup(1, 1); fixture.detectChanges(); expect(page.isYourGroupIconVisible).toBeTrue(); }); it("does not show the 'your group' icon when the user is not a member of the group", () => { - userServiceStub.currentGroupId = null; + userService.removeUserFromGroup(); fixture.detectChanges(); expect(page.isYourGroupIconVisible).toBeFalse(); }); + + it("should close the group details dialog if the group card is destroyed", () => { + page.clickCard(); + fixture.detectChanges(); + + expect(component.groupDetailsDialogRef?.getState()).toBe( + MatDialogState.OPEN, + ); + + component.ngOnDestroy(); + + expect(component.groupDetailsDialogRef?.getState()).toBeTruthy(); + expect([MatDialogState.CLOSING, MatDialogState.CLOSED]).toContain( + component.groupDetailsDialogRef!.getState(), + ); + }); }); class GroupCardPage { private readonly _cardComponent: HTMLElement; - constructor(private fixture: ComponentFixture) { + constructor(private fixture: ComponentFixture) { this._cardComponent = this.fixture.nativeElement.querySelector( '[data-test="group-card"]', ); diff --git a/src/app/groups/groupCard/groupCard.component.ts b/src/app/groups/groupCard/groupCard.component.ts index ace623f..06b5c98 100644 --- a/src/app/groups/groupCard/groupCard.component.ts +++ b/src/app/groups/groupCard/groupCard.component.ts @@ -1,5 +1,9 @@ -import { Component, ElementRef, Input } from "@angular/core"; -import { MatDialog, MatDialogConfig } from "@angular/material/dialog"; +import { Component, ElementRef, Input, OnDestroy } from "@angular/core"; +import { + MatDialog, + MatDialogConfig, + MatDialogRef, +} from "@angular/material/dialog"; import { GroupDetailsDialogComponent } from "../dialogs/groupDetailsDialog/groupDetailsDialog.component"; import { BreakpointObserver, Breakpoints } from "@angular/cdk/layout"; import { Subject, takeUntil } from "rxjs"; @@ -17,10 +21,12 @@ import { MatIconModule } from "@angular/material/icon"; standalone: true, imports: [MatCardModule, MatRippleModule, NgClass, MatIconModule], }) -export class GroupCardComponent { +export class GroupCardComponent implements OnDestroy { @Input() group!: GroupModel; private readonly destroy$ = new Subject(); + public groupDetailsDialogRef: MatDialogRef | null = + null; constructor( public dialog: MatDialog, @@ -29,13 +35,19 @@ export class GroupCardComponent { private elementRef: ElementRef, ) {} + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.groupDetailsDialogRef?.close(); + } + openDialog(): void { const dialogConfig = new MatDialogConfig(); dialogConfig.maxWidth = "100vw"; // overrides default in-line style of 80vw dialogConfig.maxHeight = "100%"; dialogConfig.data = this.group; - const dialogRef = this.dialog.open( + this.groupDetailsDialogRef = this.dialog.open( GroupDetailsDialogComponent, dialogConfig, ); @@ -46,13 +58,13 @@ export class GroupCardComponent { .pipe(takeUntil(this.destroy$)) .subscribe((result) => { if (result.matches) { - dialogRef.addPanelClass("full-screen-modal"); + this.groupDetailsDialogRef?.addPanelClass("full-screen-modal"); } else { - dialogRef.removePanelClass("full-screen-modal"); + this.groupDetailsDialogRef?.removePanelClass("full-screen-modal"); } }); - dialogRef.afterClosed().subscribe((result) => { + this.groupDetailsDialogRef.afterClosed().subscribe((result) => { console.debug("The dialog was closed. Result:", result); this.destroy$.next(); this.destroy$.complete(); diff --git a/src/app/groups/groupCards/groupCards.component.html b/src/app/groups/groupCards/groupCards.component.html index b7dd57b..6dbb546 100644 --- a/src/app/groups/groupCards/groupCards.component.html +++ b/src/app/groups/groupCards/groupCards.component.html @@ -1,32 +1,35 @@ @if (groups.length > 0) { -
- @for (group of groups; track trackByItems($index, group)) { - - } -
+
+ @for (group of groups; track trackByItems($index, group)) { + + } +
} @else { -
-

- Aw shucks! There aren't any groups active! -

- @if (isGroupsSynced) { -

- Don't worry, we'll show you them as soon as they're ready! -

- } -
+
+

+ Aw shucks! There aren't any groups active! +

+ @if (isGroupsSynced) { +

+ Don't worry, we'll show you them as soon as they're ready! +

+ } +
} diff --git a/src/app/groups/groupCards/groupCards.component.spec.ts b/src/app/groups/groupCards/groupCards.component.spec.ts index aaeb1c1..789bb17 100644 --- a/src/app/groups/groupCards/groupCards.component.spec.ts +++ b/src/app/groups/groupCards/groupCards.component.spec.ts @@ -1,11 +1,18 @@ -import { Component, Input } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { GroupCardsComponent } from "./groupCards.component"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { GroupModel } from "../../model/group.model"; import { trigger } from "@angular/animations"; -import { AppMediaBreakpointDirective } from "../../shared/directives/attr.breakpoint"; -import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; +import { ConfigService } from "../../config/config.service"; +import { Component, Input } from "@angular/core"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { RequestServiceComponentInterface } from "../../services/network/rsocket/mediators/interfaces/requestServiceComponent.interface"; +import { RsocketRequestMediatorFactory } from "../../services/network/rsocket/mediators/rsocketRequestMediator.factory"; +import { MemberModel } from "../../model/member.model"; +import { UserService } from "../../services/user/user.service"; +import { NEVER } from "rxjs"; +import { StateEnum } from "../../services/state/StateEnum"; +import { NotificationService } from "../../services/notifications/notification.service"; @Component({ standalone: true, @@ -16,61 +23,21 @@ class GroupCardStubComponent { @Input() group!: GroupModel; } -@Component({ - template: ``, - standalone: true, - imports: [GroupCardStubComponent, GroupCardsComponent], -}) -class TestHostComponent { - groups: GroupModel[] = [ - { - id: 1, - title: "Group 1", - description: "Group 1 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: Date.now().toString(), - lastModifiedBy: "Test User 1", - createdDate: Date.now().toString(), - createdBy: "Test User 1", - version: 1, - members: [], - }, - { - id: 2, - title: "Group 2", - description: "Group 2 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: Date.now().toString(), - lastModifiedBy: "Test User 2", - createdDate: Date.now().toString(), - createdBy: "Test User 2", - version: 1, - members: [], - }, - ]; - isGroupsSynced = false; - - setCards() {} -} - describe("GroupCardsComponent", () => { - let fixture: ComponentFixture; - let testHost: TestHostComponent; + let fixture: ComponentFixture; + let component: GroupCardsComponent; let page: Page; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, TestHostComponent, GroupCardsComponent], + beforeEach(() => { + const groups: Partial[] = [{ id: 1 }, { id: 2 }]; + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [{ provide: ConfigService, useValue: {} }], }) .overrideComponent(GroupCardsComponent, { set: { - imports: [GroupCardStubComponent, AppMediaBreakpointDirective], + imports: [GroupCardStubComponent], animations: [ trigger("groupBoardAnimation", []), trigger("groupBoardNoGroupsMessage", []), @@ -79,19 +46,17 @@ describe("GroupCardsComponent", () => { }) .compileComponents(); - fixture = TestBed.createComponent(TestHostComponent); - page = new Page(fixture); - testHost = fixture.componentInstance; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(GroupCardsComponent); + component = fixture.componentInstance; + component.groups = groups as GroupModel[]; - it("creates the component", () => { - expect(testHost).toBeTruthy(); + page = new Page(fixture); }); describe("groups exist state", () => { it("renders the group cards when there exists at least one group", () => { - expect(testHost.groups.length).toBeGreaterThan(0); + fixture.detectChanges(); + expect(component.groups.length).toBeGreaterThan(0); expect(page.groupCardsContainer).toBeTruthy(); expect(page.noGroupsMessageContainer).toBeFalsy(); }); @@ -99,7 +64,7 @@ describe("GroupCardsComponent", () => { describe("no groups state", () => { beforeEach(() => { - testHost.groups = []; + component.groups = []; fixture.detectChanges(); }); @@ -109,40 +74,139 @@ describe("GroupCardsComponent", () => { }); it("should render the no groups message whether synced or not", () => { - testHost.isGroupsSynced = true; + component.isGroupsSynced = true; fixture.detectChanges(); expect(page.noGroupsMessage).toBeTruthy(); - testHost.isGroupsSynced = false; + component.isGroupsSynced = false; fixture.detectChanges(); expect(page.noGroupsMessage).toBeTruthy(); }); it("should render the additional sync message when the groups are synced", () => { - testHost.isGroupsSynced = true; + component.isGroupsSynced = true; fixture.detectChanges(); expect(page.noGroupsMessageSyncedText).toBeTruthy(); }); it("should not render the additional sync message when the groups are not synced", () => { - testHost.isGroupsSynced = false; + component.isGroupsSynced = false; fixture.detectChanges(); expect(page.noGroupsMessageSyncedText).toBeFalsy(); }); }); it("renders the no groups message elements when there are no groups", () => { - testHost.groups = []; + component.groups = []; fixture.detectChanges(); expect(page.groupCardsContainer).toBeFalsy(); expect(page.noGroupsMessageContainer).toBeTruthy(); }); + + describe("member fetch", () => { + let rsocketRequestMediatorFactory: RsocketRequestMediatorFactory; + let requestServiceComponentInterfaceSpy: jasmine.SpyObj< + RequestServiceComponentInterface + >; + let userService: UserService; + let notificationService: NotificationService; + let testScheduler: TestScheduler; + + beforeEach(() => { + userService = TestBed.inject(UserService); + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, "showMessage"); + + rsocketRequestMediatorFactory = TestBed.inject( + RsocketRequestMediatorFactory, + ); + + requestServiceComponentInterfaceSpy = jasmine.createSpyObj( + "RequestServiceComponentInterface", + ["getEvents$", "getState$"], + ); + + spyOn( + rsocketRequestMediatorFactory, + "createRequestResponseMediator", + ).and.returnValue(requestServiceComponentInterfaceSpy); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + it("should set the user's group and member status if fetch is successful", () => { + testScheduler.run(({ cold, flush }) => { + const member: Partial = { id: 1, groupId: 1 }; + requestServiceComponentInterfaceSpy.getState$.and.returnValue(NEVER); + requestServiceComponentInterfaceSpy.getEvents$.and.returnValue( + cold("a|", { a: member as MemberModel }), + ); + + fixture.detectChanges(); + flush(); + + expect(userService.currentMemberId).toBe(member.id!); + expect(userService.currentGroupId).toBe(member.groupId!); + expect(notificationService.showMessage).toHaveBeenCalledTimes(0); + }); + }); + + it("should show a message if the fetch is retrying", () => { + testScheduler.run(({ cold, flush }) => { + requestServiceComponentInterfaceSpy.getState$.and.returnValue( + cold("a|", { a: StateEnum.RETRYING }), + ); + requestServiceComponentInterfaceSpy.getEvents$.and.returnValue(NEVER); + + fixture.detectChanges(); + flush(); + + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Retrying to fetch current member data...", + ); + }); + }); + + it("should show a message if the fetch request was rejected", () => { + testScheduler.run(({ cold, flush }) => { + requestServiceComponentInterfaceSpy.getState$.and.returnValue( + cold("a|", { a: StateEnum.REQUEST_REJECTED }), + ); + requestServiceComponentInterfaceSpy.getEvents$.and.returnValue(NEVER); + + fixture.detectChanges(); + flush(); + + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Failed to fetch current member data ;_;", + ); + }); + }); + + it("should show a message if the fetch request was successful after retrying", () => { + testScheduler.run(({ cold, flush }) => { + requestServiceComponentInterfaceSpy.getState$.and.returnValue( + cold("ab|", { a: StateEnum.RETRYING, b: StateEnum.REQUEST_ACCEPTED }), + ); + requestServiceComponentInterfaceSpy.getEvents$.and.returnValue(NEVER); + + fixture.detectChanges(); + flush(); + + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Successfully fetched member data!", + ); + }); + }); + }); }); class Page { private readonly _testHostComponent: HTMLElement; - constructor(readonly fixture: ComponentFixture) { + constructor(readonly fixture: ComponentFixture) { this._testHostComponent = fixture.nativeElement; } diff --git a/src/app/groups/groupCards/groupCards.component.ts b/src/app/groups/groupCards/groupCards.component.ts index 9deacf2..966c111 100644 --- a/src/app/groups/groupCards/groupCards.component.ts +++ b/src/app/groups/groupCards/groupCards.component.ts @@ -3,6 +3,8 @@ import { Component, EventEmitter, Input, + OnDestroy, + OnInit, Output, QueryList, ViewChildren, @@ -11,6 +13,13 @@ import { GroupBoardAnimation } from "./groupCards.animation"; import { GroupModel } from "../../model/group.model"; import { GroupCardComponent } from "../groupCard/groupCard.component"; import { AppMediaBreakpointDirective } from "../../shared/directives/attr.breakpoint"; +import { RequestServiceComponentInterface } from "../../services/network/rsocket/mediators/interfaces/requestServiceComponent.interface"; +import { MemberModel } from "../../model/member.model"; +import { StateEnum } from "../../services/state/StateEnum"; +import { RsocketRequestMediatorFactory } from "../../services/network/rsocket/mediators/rsocketRequestMediator.factory"; +import { UserService } from "../../services/user/user.service"; +import { NotificationService } from "../../services/notifications/notification.service"; +import { finalize, Subscription } from "rxjs"; @Component({ selector: "app-group-cards", @@ -20,7 +29,7 @@ import { AppMediaBreakpointDirective } from "../../shared/directives/attr.breakp standalone: true, imports: [AppMediaBreakpointDirective, GroupCardComponent], }) -export class GroupCardsComponent implements AfterViewInit { +export class GroupCardsComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChildren(GroupCardComponent) itemElements!: QueryList; @@ -33,6 +42,67 @@ export class GroupCardsComponent implements AfterViewInit { @Input() isGroupsSynced = false; + private memberRequestState: StateEnum = StateEnum.INITIALIZING; + + private subscriptions: Subscription = new Subscription(); + + constructor( + readonly rsocketRequestMediatorFactory: RsocketRequestMediatorFactory, + readonly userService: UserService, + readonly notificationService: NotificationService, + ) {} + + ngOnInit(): void { + // TODO: Move this fetch logic and its tests to a service class? + const fetchCurrentMember: RequestServiceComponentInterface = + this.rsocketRequestMediatorFactory.createRequestResponseMediator< + unknown, + MemberModel + >("groups.user.member"); + + const fetchMemberStatusSubscription = fetchCurrentMember + .getState$() + .subscribe((state) => { + console.log( + `Current state: ${this.memberRequestState}, new state: ${state}`, + ); + switch (state) { + case StateEnum.REQUEST_ACCEPTED: + case StateEnum.REQUEST_COMPLETED: + if (this.memberRequestState === StateEnum.RETRYING) { + this.memberRequestState = state; + this.notificationService.showMessage( + "Successfully fetched member data!", + ); + } + break; + case StateEnum.RETRYING: + this.memberRequestState = StateEnum.RETRYING; + this.notificationService.showMessage( + "Retrying to fetch current member data...", + ); + break; + case StateEnum.REQUEST_REJECTED: + this.memberRequestState = StateEnum.REQUEST_REJECTED; + this.notificationService.showMessage( + "Failed to fetch current member data ;_;", + ); + break; + } + }); + + const fetchMemberSubscription = fetchCurrentMember + .getEvents$(true) + .pipe(finalize(() => this.subscriptions.unsubscribe())) + .subscribe((member) => { + console.debug("Fetched member for user:", member); + this.userService.setUserInGroup(member.groupId, member.id); + }); + + this.subscriptions.add(fetchMemberStatusSubscription); + this.subscriptions.add(fetchMemberSubscription); + } + ngAfterViewInit() { console.debug("Emitting group cards"); this.groupCards.emit(this.itemElements); @@ -41,4 +111,8 @@ export class GroupCardsComponent implements AfterViewInit { trackByItems(index: number, item: GroupModel): number { return item.id; } + + ngOnDestroy() { + this.subscriptions.unsubscribe(); + } } diff --git a/src/app/groups/groupUtilityBar/groupUtilityBar.component.spec.ts b/src/app/groups/groupUtilityBar/groupUtilityBar.component.spec.ts index 7998c0e..cee3908 100644 --- a/src/app/groups/groupUtilityBar/groupUtilityBar.component.spec.ts +++ b/src/app/groups/groupUtilityBar/groupUtilityBar.component.spec.ts @@ -1,7 +1,5 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { GroupUtilityBarComponent } from "./groupUtilityBar.component"; -import { MatFormFieldModule } from "@angular/material/form-field"; -import { MatSelectModule } from "@angular/material/select"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { GroupSortEnum } from "../../model/enums/groupSort.enum"; import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; @@ -15,12 +13,7 @@ describe("GroupUtilityBarComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - MatFormFieldModule, - MatSelectModule, - GroupUtilityBarComponent, - ], + imports: [NoopAnimationsModule], }).compileComponents(); fixture = TestBed.createComponent(GroupUtilityBarComponent); diff --git a/src/app/groups/groupUtilityBar/groupUtilityBar.component.ts b/src/app/groups/groupUtilityBar/groupUtilityBar.component.ts index dda1361..b34d06e 100644 --- a/src/app/groups/groupUtilityBar/groupUtilityBar.component.ts +++ b/src/app/groups/groupUtilityBar/groupUtilityBar.component.ts @@ -1,10 +1,10 @@ import { Component } from "@angular/core"; -import { GroupsService } from "../services/groups.service"; import { GroupSortEnum } from "../../model/enums/groupSort.enum"; import { MatOptionModule } from "@angular/material/core"; import { MatSelectModule } from "@angular/material/select"; import { MatFormFieldModule } from "@angular/material/form-field"; import { AppMediaBreakpointDirective } from "../../shared/directives/attr.breakpoint"; +import { GroupSortingService } from "../services/groupSorting.service"; @Component({ selector: "app-group-utility-bar", @@ -22,10 +22,10 @@ export class GroupUtilityBarComponent { public selected = GroupSortEnum.OLDEST; public GroupSortEnum = GroupSortEnum; - constructor(private groupService: GroupsService) {} + constructor(private groupSortingService: GroupSortingService) {} onSortChange() { console.debug("Sort changed to: ", this.selected); - this.groupService.changeSort(this.selected); + this.groupSortingService.changeSort = this.selected; } } diff --git a/src/app/groups/services/groupManager.service.spec.ts b/src/app/groups/services/groupManager.service.spec.ts index a1e149b..9fd4953 100644 --- a/src/app/groups/services/groupManager.service.spec.ts +++ b/src/app/groups/services/groupManager.service.spec.ts @@ -1,198 +1,315 @@ +import { GroupManagerService } from "./groupManager.service"; import { TestBed } from "@angular/core/testing"; -import { GroupModel } from "../../model/group.model"; -import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; -import { GroupManagerService, GroupUpdate } from "./groupManager.service"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { GroupSortingService } from "./groupSorting.service"; +import { GroupSortEnum } from "../../model/enums/groupSort.enum"; +import { EventStreamService } from "../../services/notifications/eventStream.service"; +import { StateUpdateService } from "./stateUpdate.service"; +import { StateEnum } from "../../services/state/StateEnum"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { GroupsService } from "./groups.service"; -import { PublicEventModel } from "../../model/publicEvent.model"; +import { EMPTY, of } from "rxjs"; +import { FlipService } from "../../services/animation/flip.service"; +import { QueryList } from "@angular/core"; +import { PublicEventModel } from "../../model/events/publicEvent.model"; import { EventTypeEnum } from "../../model/enums/eventType.enum"; +import { GroupModel } from "../../model/group.model"; +import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; +import { EventStatusEnum } from "../../model/enums/eventStatus.enum"; describe("GroupManagerService", () => { let service: GroupManagerService; - let groups: GroupModel[] = []; - const date = new Date(99, 0, 1, 0, 0, 0); - const groupDates = [ - new Date(date.getTime() - 3000).toString(), - new Date(date.getTime() - 2000).toString(), - new Date(date.getTime() - 1000).toString(), - ]; + let eventStreamServiceSpy: jasmine.SpyObj; + let groupService: GroupsService; + let groupStateService: StateUpdateService; + let flipService: FlipService; + let testScheduler: TestScheduler; beforeEach(() => { + eventStreamServiceSpy = jasmine.createSpyObj("EventStreamService", [ + "stream", + "streamStatus", + "retryTime", + ]); + TestBed.configureTestingModule({ - providers: [GroupManagerService, GroupsService], + imports: [NoopAnimationsModule], + providers: [ + GroupManagerService, + { + provide: EventStreamService, + useValue: eventStreamServiceSpy, + }, + ], }); + service = TestBed.inject(GroupManagerService); - groups = [ - { - id: 1, - title: "Group 1", - description: "Group 1 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: groupDates[0], - lastModifiedBy: "Test User 1", - createdDate: groupDates[0], - createdBy: "Test User 1", - version: 1, - members: [], - }, - { - id: 2, - title: "Group 2", - description: "Group 2 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: groupDates[1], - lastModifiedBy: "Test User 2", - createdDate: groupDates[1], - createdBy: "Test User 2", - version: 1, - members: [], - }, - ]; - }); + groupService = TestBed.inject(GroupsService); + groupStateService = TestBed.inject(StateUpdateService); + flipService = TestBed.inject(FlipService); - it("should be created", () => { - expect(service).toBeTruthy(); + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); }); - describe("#handleUpdates", () => { - function assertGroupUpdate(assertions: () => void) { - const subscription = service.groupUpdateActions$.subscribe( - (groupUpdate: GroupUpdate) => { - groupUpdate.updateFunction(); - assertions(); - subscription.unsubscribe(); - }, - ); - } + describe("exposed members", () => { + it("exposes observable status updates from the state service", () => { + expect(service.groupState$).toEqual(groupStateService.requestState$); + }); - it("should add a group when a group created event is received", () => { - const group: GroupModel = { - id: 3, - title: "Group 3", - description: "Group 3 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: groupDates[2], - lastModifiedBy: "Test User 3", - createdDate: groupDates[2], - createdBy: "Test User 3", - version: 1, - members: [], - }; - const event = { - eventType: EventTypeEnum.GROUP_CREATED, - aggregateId: group.id, - eventData: JSON.stringify(group), - } as PublicEventModel; - - assertGroupUpdate(() => - expect(service.groups.getValue()).toContain(group), + it("exposes the current status from the state service", () => { + expect(service.groupState).toEqual(groupStateService.requestState); + }); + + it("exposes the current component state observable from the state service", () => { + expect(service.componentState$).toEqual( + groupStateService.componentState$, ); + }); + + it("exposes the current component state from the state service", () => { + expect(service.componentState).toEqual(groupStateService.componentState); + }); + + it("exposes the current group route", () => { + eventStreamServiceSpy.stream.and.returnValue(EMPTY); + eventStreamServiceSpy.streamStatus.and.returnValue(EMPTY); + + service.subscribeToGroupsStream("testRoute"); + + expect(service.currentGroupRoute).toEqual("testRoute"); + }); + + it("exposes the current groups observable from group service", () => { + expect(service.groups$).toEqual(groupService.groups$); + }); + + it("exposes the current groups from group service", () => { + expect(service.groups).toEqual(groupService.groups); + }); + }); + + describe("event status updates", () => { + it("delegates status updates to group state service", () => { + spyOn(groupStateService, "handleNewRequestState").and.callThrough(); + const statusUpdates = [ + StateEnum.INITIALIZING, + StateEnum.LOADING, + StateEnum.REQUESTING, + StateEnum.READY, + ]; + + testScheduler.run(({ cold, flush }) => { + const streamStatusUpdates = cold("a - b - c - d", { + a: statusUpdates[0], + b: statusUpdates[1], + c: statusUpdates[2], + d: statusUpdates[3], + }); + + eventStreamServiceSpy.stream.and.returnValue(cold("-")); + eventStreamServiceSpy.streamStatus.and.returnValue(streamStatusUpdates); + + service.subscribeToGroupsStream("testRoute"); - service.handleUpdates(event); + flush(); + + for (const status of statusUpdates) { + expect(groupStateService.handleNewRequestState).toHaveBeenCalledWith( + status, + ); + } + }); }); + }); - it("should remove a group if group status changes from 'ACTIVE'", () => { - const group = groups[0]; + describe("event handling updates", () => { + let groupIdCounter = 0; - const updatedGroup: GroupModel = { - id: 1, + function createGroup(status: GroupStatusEnum, id?: number): GroupModel { + return { + id: id ?? ++groupIdCounter, title: "Group 1", description: "Group 1 description", - status: GroupStatusEnum.AUTO_DISBANDED, maxGroupSize: 10, - lastModifiedDate: new Date().toString(), - lastModifiedBy: "Test User 1", - createdDate: groupDates[0], + createdDate: new Date().toISOString(), + lastModifiedDate: new Date().toISOString(), createdBy: "Test User 1", - version: 2, + lastModifiedBy: "Test User 1", + version: 1, + status, members: [], }; + } + + function createEvent( + eventType: EventTypeEnum, + eventStatus: EventStatusEnum, + eventData: any, + eventAggregateId?: number, + ): Partial { + return { + eventType, + eventStatus, + eventData, + aggregateId: eventAggregateId ?? 0, + }; + } + + function runEvents(events: Partial[]) { + testScheduler.run(({ cold, flush }) => { + let marbles = ""; + const values: { [key: string]: Partial } = {}; + + events.forEach((event, index) => { + const key = String.fromCharCode(97 + index); // ASCII 'a' starts at 97 + marbles += key; + values[key] = event; + }); - const event = { - eventType: EventTypeEnum.GROUP_UPDATED, - aggregateId: group.id, - eventData: JSON.stringify(updatedGroup), - } as PublicEventModel; + console.log("Observable info", marbles, values); + const events$ = cold(`(${marbles})`, values); - assertGroupUpdate(() => - expect( - service.groups.getValue().map((group) => group.id), - ).not.toContain(group.id), + eventStreamServiceSpy.stream.and.returnValue(events$); + eventStreamServiceSpy.streamStatus.and.returnValue(EMPTY); + + service.subscribeToGroupsStream("testRoute"); + + flush(); + }); + } + + it("handles events", () => { + const group = createGroup(GroupStatusEnum.ACTIVE); + const eventA = createEvent( + EventTypeEnum.GROUP_CREATED, + EventStatusEnum.SUCCESSFUL, + group, ); - service.handleUpdates(event); + runEvents([eventA]); + + spyOn(flipService, "animate").and.callThrough(); + + expect(groupService.groups).toContain(group); + expect(flipService.animate).toHaveBeenCalledTimes(0); }); - it("should update the group size when a MEMBER_JOINED event is received", () => { - const group = groups[0]; + it("calls flip service to animate the change when the component state is ready (currently after the first event emitted)", () => { + const eventA = createEvent( + EventTypeEnum.GROUP_CREATED, + EventStatusEnum.SUCCESSFUL, + createGroup(GroupStatusEnum.ACTIVE), + ); + const eventB = createEvent( + EventTypeEnum.GROUP_UPDATED, + EventStatusEnum.SUCCESSFUL, + createGroup(GroupStatusEnum.ACTIVE), + ); - const event = { - eventType: EventTypeEnum.MEMBER_JOINED, - aggregateId: group.id, - eventData: JSON.stringify({}), - } as PublicEventModel; + spyOn(flipService, "animate").and.callThrough(); + runEvents([eventA, eventB]); + + expect(service.groups.length).toBe(2); + expect(flipService.animate).toHaveBeenCalledTimes(1); + }); - assertGroupUpdate(() => - expect(service.groups.getValue()[0].members.length).toBe( - group.members.length + 1, - ), + it("calls flip service to animate a group removal when the event type is GROUP_UPDATED and group status is not active", () => { + const addEvent = createEvent( + EventTypeEnum.GROUP_CREATED, + EventStatusEnum.SUCCESSFUL, + createGroup(GroupStatusEnum.ACTIVE, 1), ); + const removeEvent = createEvent( + EventTypeEnum.GROUP_UPDATED, + EventStatusEnum.SUCCESSFUL, + createGroup(GroupStatusEnum.DISBANDED, 1), + 1, + ); + + spyOn(flipService, "animateRemoval").and.callThrough(); + + runEvents([addEvent, removeEvent]); - service.handleUpdates(event); + expect(service.groups.length).toBe(0); + expect(flipService.animateRemoval).toHaveBeenCalledTimes(1); // 1 because first event is not animated }); - it("should update the group size when a MEMBER_LEFT event is received", () => { - const group = groups[0]; + it("performs the change without animation if flip service throws an error", () => { + const eventA = createEvent( + EventTypeEnum.GROUP_CREATED, + EventStatusEnum.SUCCESSFUL, + createGroup(GroupStatusEnum.ACTIVE), + ); + const eventB = createEvent( + EventTypeEnum.GROUP_UPDATED, + EventStatusEnum.SUCCESSFUL, + createGroup(GroupStatusEnum.ACTIVE), + ); - const event = { - eventType: EventTypeEnum.MEMBER_LEFT, - aggregateId: group.id, - eventData: JSON.stringify({}), - } as PublicEventModel; + spyOn(flipService, "animate").and.throwError("Test error"); + runEvents([eventA, eventB]); - assertGroupUpdate(() => - expect(service.groups.getValue()[0].members.length).toBe( - group.members.length - 1, - ), + expect(service.groups.length).toBe(2); + expect(flipService.animate).toThrowError("Test error"); + }); + + it("performs the change without calling flip service if the component state is not ready", () => { + const eventA = createEvent( + EventTypeEnum.GROUP_CREATED, + EventStatusEnum.SUCCESSFUL, + createGroup(GroupStatusEnum.ACTIVE), ); - service.handleUpdates(event); + spyOn(flipService, "animate").and.callThrough(); + runEvents([eventA]); + + expect(service.groups.length).toBe(1); + expect(flipService.animate).toHaveBeenCalledTimes(0); }); + }); - describe("group integrity", () => { - function assertIntegrity(eventType: EventTypeEnum) { - const groupsCopy = [...groups]; - const event = { - eventType: eventType, - aggregateId: Number.MAX_VALUE, - eventData: JSON.stringify({}), - } as PublicEventModel; + describe("stream retry time", () => { + it("exposes the current stream retry time if the current route is set", () => { + eventStreamServiceSpy.stream.and.returnValue(EMPTY); + eventStreamServiceSpy.streamStatus.and.returnValue(EMPTY); - assertGroupUpdate(() => - expect(service.groups.getValue).toEqual(groupsCopy), - ); + service.subscribeToGroupsStream("testRoute"); - service.handleUpdates(event); - } + const mockRetryTime = of(1000); + eventStreamServiceSpy.retryTime.and.returnValue(mockRetryTime); + expect(service.streamRetryTime).toEqual(mockRetryTime); + }); - it("does not change groups if group is invalid for GROUP_CREATED event", () => { - assertIntegrity(EventTypeEnum.GROUP_CREATED); - }); + it("should throw an error if the current route is not set", () => { + expect(() => service.streamRetryTime).toThrowError(); + }); + }); - it("does not change groups if group doesn't exist for GROUP_STATUS_UPDATED event", () => { - assertIntegrity(EventTypeEnum.GROUP_UPDATED); - }); + describe("sort handling updates", () => { + it("should sort the groups when the sort type changes", () => { + const groupSortingService = TestBed.inject(GroupSortingService); - it("does not change groups if group doesn't exist for MEMBER_JOINED event", () => { - assertIntegrity(EventTypeEnum.MEMBER_JOINED); - }); + spyOn(groupSortingService, "sortGroups").and.callThrough(); - it("does not change groups if group doesn't exist for MEMBER_LEFT event", () => { - assertIntegrity(EventTypeEnum.MEMBER_LEFT); - }); + groupSortingService.changeSort = GroupSortEnum.NEWEST; + + expect(groupSortingService.sortGroups).toHaveBeenCalled(); + }); + }); + + describe("#setCardComponents", () => { + it("should set the card components on FlipService", () => { + spyOn(flipService, "setComponents").and.callThrough(); + + expect(() => + service.setCardComponents(new QueryList(), null as any), + ).not.toThrowError(); + expect(flipService.setComponents).toHaveBeenCalledOnceWith( + jasmine.any(QueryList), + ); }); }); }); diff --git a/src/app/groups/services/groupManager.service.ts b/src/app/groups/services/groupManager.service.ts index 91db2e9..3486f43 100644 --- a/src/app/groups/services/groupManager.service.ts +++ b/src/app/groups/services/groupManager.service.ts @@ -1,136 +1,158 @@ -import { Injectable } from "@angular/core"; +import { ChangeDetectorRef, Injectable, QueryList } from "@angular/core"; import { GroupsService } from "./groups.service"; -import { PublicEventModel } from "../../model/publicEvent.model"; +import { PublicEventModel } from "../../model/events/publicEvent.model"; import { GroupModel } from "../../model/group.model"; import { EventTypeEnum } from "../../model/enums/eventType.enum"; -import { BehaviorSubject, Subject } from "rxjs"; -import { MemberModel } from "../../model/member.model"; - -export interface GroupUpdate { - updateFunction: () => void; - eventType: EventTypeEnum | "SORT"; - groupId: number; -} +import { map, Subscription } from "rxjs"; +import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; +import { EventStreamService } from "../../services/notifications/eventStream.service"; +import { StateEnum } from "../../services/state/StateEnum"; +import { FlipService } from "../../services/animation/flip.service"; +import { StateUpdateService } from "./stateUpdate.service"; +import { GroupSortingService } from "./groupSorting.service"; +import { GroupEventVisitor } from "../../services/notifications/visitors/group/groupEvent.visitor"; @Injectable({ providedIn: "root", }) export class GroupManagerService { - private _groupUpdateActions$ = new Subject(); - public readonly groups = new BehaviorSubject([]); - - constructor(private readonly groupService: GroupsService) { + private subscriptions: Subscription = new Subscription(); + private _currentGroupRoute: string | undefined; + private _changeDetectorRef: ChangeDetectorRef | undefined; + + constructor( + private readonly groupService: GroupsService, + private readonly groupSortingService: GroupSortingService, + private readonly groupEventVisitor: GroupEventVisitor, + private readonly groupStateService: StateUpdateService, + private readonly eventStreamService: EventStreamService, + private readonly flipService: FlipService, + ) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - this.groupService.currentSort.subscribe((_) => { - this.triggerSort(); + this.groupSortingService.currentSort$.subscribe((_) => { + this.commitChange(() => this.groupSortingService.sortGroups(this.groups)); }); } get groups$() { - return this.groups.asObservable(); + return this.groupService.groups$; } - get groupUpdateActions$() { - return this._groupUpdateActions$.asObservable(); + get groups() { + return this.groupService.groups; } - handleUpdates(publicEvent: PublicEventModel) { - console.debug("Handling update"); - console.debug(publicEvent); - - const group = this.parseIfJson(publicEvent.eventData) as GroupModel; - - switch (publicEvent.eventType) { - case EventTypeEnum.GROUP_CREATED: - this.addGroup(group); - break; - case EventTypeEnum.GROUP_UPDATED: - this.updateGroup(group); - break; - case EventTypeEnum.MEMBER_JOINED: { - const groupJoined = this.groups - .getValue() - .find((group) => group.id === publicEvent.aggregateId); - if (!groupJoined) return; - const member = this.parseIfJson(publicEvent.eventData) as MemberModel; - this.updateGroupSize(member, groupJoined, EventTypeEnum.MEMBER_JOINED); - break; - } - case EventTypeEnum.MEMBER_LEFT: { - const groupLeft = this.groups - .getValue() - .find((group) => group.id === publicEvent.aggregateId); - if (!groupLeft) return; - const member = this.parseIfJson(publicEvent.eventData) as MemberModel; - this.updateGroupSize(member, groupLeft, EventTypeEnum.MEMBER_LEFT); - break; - } - default: - break; - } + get currentGroupRoute() { + return this._currentGroupRoute; } - private addGroup(groupToAdd: GroupModel) { - if (!groupToAdd || !groupToAdd.id || !groupToAdd.title) { - return; - } - console.debug("Pushing groups"); - - this._groupUpdateActions$.next({ - updateFunction: () => { - this.groupService.insertGroup(groupToAdd, this.groups.getValue()); - }, - eventType: EventTypeEnum.GROUP_CREATED, - groupId: groupToAdd.id, - }); + get streamRetryTime() { + if (!this._currentGroupRoute) throw new Error("No current group route"); + return this.eventStreamService.retryTime(this._currentGroupRoute); + } + + get groupState$() { + return this.groupStateService.requestState$; + } + + get groupState() { + return this.groupStateService.requestState; + } + + get componentState$() { + return this.groupStateService.componentState$; + } + + get componentState() { + return this.groupStateService.componentState; + } + + public subscribeToGroupsStream(route: string) { + this.subscriptions.unsubscribe(); + this.subscriptions = new Subscription(); + this._currentGroupRoute = route; + + const groupEventStreamSubscription = this.eventStreamService + .stream(route) + .pipe(map((event) => PublicEventModel.instantiate(event))) + .subscribe((event) => { + console.debug(`Received ${route} event: `, event); + const change = () => + this.groupStateService.handleEventAndUpdateStates( + event, + this.groupEventVisitor, + ); + this.commitChange(change, event); + }); + + const groupEventStreamStatusSubscription = this.eventStreamService + .streamStatus(route) + .subscribe((status) => { + console.debug(`Received ${route} status: `, status); + this.groupStateService.handleNewRequestState(status); + }); + + this.subscriptions.add(groupEventStreamSubscription); + this.subscriptions.add(groupEventStreamStatusSubscription); } - private updateGroup(updatedGroup: GroupModel) { - this.groups.next( - this.groupService.updateGroup(updatedGroup, this.groups.getValue()), + private commitChange(changeFunction: () => void, event?: PublicEventModel) { + this.animate( + changeFunction, + event ? this.mapEventTypeIfGroupDisbanded(event) : EventTypeEnum.NONE, + event ? event.aggregateId : 0, ); } - private updateGroupSize( - member: MemberModel, - group: GroupModel, - event: EventTypeEnum.MEMBER_JOINED | EventTypeEnum.MEMBER_LEFT, - ) { - let memberListUpdated; - if (event === EventTypeEnum.MEMBER_JOINED) { - memberListUpdated = this.groupService.addMember(member, group); - } else { - console.debug("Removing member"); - memberListUpdated = this.groupService.removeMember(member.id, group); + /** + * Currently, the backend returns a GROUP_UPDATED event when a group is disbanded. + * This may change in the future. + * @param event An event + * @private + */ + private mapEventTypeIfGroupDisbanded(event: PublicEventModel) { + if (event.eventType === EventTypeEnum.GROUP_UPDATED) { + const updatedGroup = event.eventData as GroupModel; + if (updatedGroup.status !== GroupStatusEnum.ACTIVE) { + return EventTypeEnum.GROUP_DISBANDED; + } } - if (memberListUpdated && this.groupService.shouldResortAfterSizeChange()) { - this.triggerSort(); - } + return event.eventType; } - triggerSort() { - this._groupUpdateActions$.next({ - updateFunction: () => - this.groups.next(this.groupService.sortGroups(this.groups.getValue())), - eventType: "SORT", - groupId: -1, - }); - } + private animate( + changeFunction: () => void, + eventType: EventTypeEnum, + groupId: number, + ) { + // Note that currently, the first event processed will always be in a non-ready state + if (this.groupStateService.componentState !== StateEnum.READY) { + changeFunction(); + return; + } - private parseIfJson(maybeJson: string | T): T { - if (typeof maybeJson === "string") { - try { - maybeJson = JSON.parse(maybeJson) as T; - } catch (error) { - console.error( - `Error parsing event data to object for ${maybeJson}`, - error, - ); - throw new Error("Invalid JSON string"); + try { + if (eventType === EventTypeEnum.GROUP_DISBANDED) { + this.flipService.animateRemoval(changeFunction, groupId.toString()); + } else { + this.flipService.animate(changeFunction, this._changeDetectorRef); } + } catch (e) { + console.error( + "Falling back to no animation. There was an error animating group change", + e, + ); + changeFunction(); } + } - return maybeJson as T; + public setCardComponents( + components: QueryList, + changeDetectorRef: ChangeDetectorRef, + ) { + console.debug("Setting card components"); + this._changeDetectorRef = changeDetectorRef; + this.flipService.setComponents(components); } } diff --git a/src/app/groups/services/groupSorting.service.spec.ts b/src/app/groups/services/groupSorting.service.spec.ts new file mode 100644 index 0000000..1429721 --- /dev/null +++ b/src/app/groups/services/groupSorting.service.spec.ts @@ -0,0 +1,171 @@ +import { GroupSortingService } from "./groupSorting.service"; +import { TestBed } from "@angular/core/testing"; +import { GroupSortEnum } from "../../model/enums/groupSort.enum"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { of, tap } from "rxjs"; + +describe("GroupSortingService", () => { + let service: GroupSortingService; + let testScheduler: TestScheduler; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [GroupSortingService], + }); + + service = TestBed.inject(GroupSortingService); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + describe("#sortGroups", () => { + it("should sort the groups by created date in ascending order (i.e. earlier date first)", () => { + service.changeSort = GroupSortEnum.OLDEST; + + const groups = [ + { createdDate: new Date("2021-01-03") }, + { createdDate: new Date("2021-01-01") }, + { createdDate: new Date("2021-01-02") }, + ]; + + const sortedGroups = service.sortGroups(groups as any); + + expect(sortedGroups as any).toEqual([ + { createdDate: new Date("2021-01-01") }, + { createdDate: new Date("2021-01-02") }, + { createdDate: new Date("2021-01-03") }, + ]); + }); + + it("should sort the groups by created date in descending order (i.e. later date first)", () => { + service.changeSort = GroupSortEnum.NEWEST; + + const groups = [ + { createdDate: new Date("2021-01-03") }, + { createdDate: new Date("2021-01-01") }, + { createdDate: new Date("2021-01-02") }, + ]; + + const sortedGroups = service.sortGroups(groups as any); + + expect(sortedGroups as any).toEqual([ + { createdDate: new Date("2021-01-03") }, + { createdDate: new Date("2021-01-02") }, + { createdDate: new Date("2021-01-01") }, + ]); + }); + + it("should sort the groups by most members", () => { + service.changeSort = GroupSortEnum.MOST_MEMBERS; + + const groups = [ + { members: [1, 2, 3] }, + { members: [1, 2] }, + { members: [1] }, + ]; + + const sortedGroups = service.sortGroups(groups as any); + + expect(sortedGroups as any).toEqual([ + { members: [1, 2, 3] }, + { members: [1, 2] }, + { members: [1] }, + ]); + }); + + it("should sort the groups by least members", () => { + service.changeSort = GroupSortEnum.LEAST_MEMBERS; + + const groups = [ + { members: [1] }, + { members: [1, 2] }, + { members: [1, 2, 3] }, + ]; + + const sortedGroups = service.sortGroups(groups as any); + + expect(sortedGroups as any).toEqual([ + { members: [1] }, + { members: [1, 2] }, + { members: [1, 2, 3] }, + ]); + }); + }); + + describe("#sortMembers", () => { + it("should sort the members by join date in ascending order (i.e. earlier date first)", () => { + const members = [ + { joinedDate: new Date("2021-01-03") }, + { joinedDate: new Date("2021-01-01") }, + { joinedDate: new Date("2021-01-02") }, + ]; + + const sortedMembers = service.sortMembers(members as any); + + expect(sortedMembers as any).toEqual([ + { joinedDate: new Date("2021-01-01") }, + { joinedDate: new Date("2021-01-02") }, + { joinedDate: new Date("2021-01-03") }, + ]); + }); + }); + + describe("currentSort", () => { + it("should return the current sort type", () => { + expect(service.currentSort).toEqual(GroupSortEnum.OLDEST); + }); + }); + + describe("currentSort$", () => { + it("should allow the sort type to be observed", () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + + const sortUpdated = of(GroupSortEnum.NEWEST, GroupSortEnum.OLDEST).pipe( + tap((sort) => (service.changeSort = sort)), + ); + + expectObservable(service.currentSort$).toBe("(abc)", { + a: GroupSortEnum.OLDEST, // Initial value set in service + b: GroupSortEnum.NEWEST, + c: GroupSortEnum.OLDEST, + }); + + expectObservable(sortUpdated).toBe("(ab|)", { + a: GroupSortEnum.NEWEST, + b: GroupSortEnum.OLDEST, + }); + }); + }); + }); + + describe("changeSort", () => { + it("should allow the sort type to be changed", () => { + service.changeSort = GroupSortEnum.NEWEST; + expect(service.currentSort).toEqual(GroupSortEnum.NEWEST); + + service.changeSort = GroupSortEnum.OLDEST; + expect(service.currentSort).toEqual(GroupSortEnum.OLDEST); + }); + }); + + describe("shouldSortGroupAfterSizeChange", () => { + it("should return true if the current sort type is most members or least members", () => { + service.changeSort = GroupSortEnum.MOST_MEMBERS; + expect(service.shouldSortGroupAfterSizeChange()).toBeTrue(); + + service.changeSort = GroupSortEnum.LEAST_MEMBERS; + expect(service.shouldSortGroupAfterSizeChange()).toBeTrue(); + }); + + it("should return false if the current sort type is newest members or oldest members", () => { + service.changeSort = GroupSortEnum.NEWEST; + expect(service.shouldSortGroupAfterSizeChange()).toBeFalse(); + + service.changeSort = GroupSortEnum.OLDEST; + expect(service.shouldSortGroupAfterSizeChange()).toBeFalse(); + }); + }); +}); diff --git a/src/app/groups/services/groupSorting.service.ts b/src/app/groups/services/groupSorting.service.ts new file mode 100644 index 0000000..b2d6714 --- /dev/null +++ b/src/app/groups/services/groupSorting.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from "@angular/core"; +import { GroupModel } from "../../model/group.model"; +import { GroupSortEnum } from "../../model/enums/groupSort.enum"; +import { MemberModel } from "../../model/member.model"; +import { BehaviorSubject } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class GroupSortingService { + private readonly _sortSource$ = new BehaviorSubject( + GroupSortEnum.OLDEST, + ); + + get currentSort() { + return this._sortSource$.getValue(); + } + + get currentSort$() { + return this._sortSource$; + } + + set changeSort(sort: GroupSortEnum) { + this._sortSource$.next(sort); + } + + public sortGroups(groups: GroupModel[]): GroupModel[] { + switch (this.currentSort) { + case GroupSortEnum.OLDEST: + return this.sortGroupsByCreatedDate(groups, true); + case GroupSortEnum.NEWEST: + return this.sortGroupsByCreatedDate(groups, false); + case GroupSortEnum.MOST_MEMBERS: + return this.sortGroupsByCurrentGroupSize(groups, false); + case GroupSortEnum.LEAST_MEMBERS: + return this.sortGroupsByCurrentGroupSize(groups, true); + default: + return groups; + } + } + + public sortMembers(members: MemberModel[]): MemberModel[] { + return this.sortMembersByJoinDate(members); + } + + private sortGroupsByCreatedDate( + groups: GroupModel[], + reverse: boolean, + ): GroupModel[] { + return groups.sort((a, b) => { + const aDate = new Date(a.createdDate); + const bDate = new Date(b.createdDate); + const difference = bDate.getTime() - aDate.getTime(); + return reverse ? -difference : difference; + }); + } + + private sortGroupsByCurrentGroupSize( + groups: GroupModel[], + reverse: boolean, + ): GroupModel[] { + return groups.sort((a, b) => { + const order = b.members.length - a.members.length; + return reverse ? -order : order; + }); + } + + public shouldSortGroupAfterSizeChange(): boolean { + const sortCriteria = this._sortSource$.getValue(); + return ( + sortCriteria === GroupSortEnum.MOST_MEMBERS || + sortCriteria === GroupSortEnum.LEAST_MEMBERS + ); + } + + private sortMembersByJoinDate(members: MemberModel[]): MemberModel[] { + return members.sort( + (a, b) => + new Date(a.joinedDate).getTime() - new Date(b.joinedDate).getTime(), + ); + } +} diff --git a/src/app/groups/services/groups.service.spec.ts b/src/app/groups/services/groups.service.spec.ts index 6be17af..fa7d0c2 100644 --- a/src/app/groups/services/groups.service.spec.ts +++ b/src/app/groups/services/groups.service.spec.ts @@ -1,338 +1,345 @@ -import { TestBed } from "@angular/core/testing"; import { GroupsService } from "./groups.service"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { TestBed } from "@angular/core/testing"; +import { of, tap } from "rxjs"; +import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; import { GroupModel } from "../../model/group.model"; +import { GroupSortingService } from "./groupSorting.service"; import { GroupSortEnum } from "../../model/enums/groupSort.enum"; -import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; import { MemberModel } from "../../model/member.model"; -import { MemberStatusEnum } from "../../model/enums/memberStatus.enum"; + +let groupIdCounter = 0; + +function createGroup(members?: MemberModel[], other?: Partial) { + const group: Partial = { + id: ++groupIdCounter, + status: GroupStatusEnum.ACTIVE, + members: members ?? [], + ...other, + }; + + return group as any; +} + +function createMembers(count: number) { + return Array.from( + { length: count }, + (_, i): Partial => ({ id: i + 1 }), + ) as any; +} describe("GroupsService", () => { let service: GroupsService; - let groups: GroupModel[] = []; - const date = new Date(99, 0, 1, 0, 0, 0); - const groupDates = [ - new Date(date.getTime() - 3000).toString(), - new Date(date.getTime() - 2000).toString(), - new Date(date.getTime() - 1000).toString(), - ]; + let groupSortingService: GroupSortingService; + let testScheduler: TestScheduler; beforeEach(() => { + groupIdCounter = 0; + TestBed.configureTestingModule({ providers: [GroupsService], }); + service = TestBed.inject(GroupsService); + groupSortingService = TestBed.inject(GroupSortingService); - groups = [ - { - id: 1, - title: "Group 1", - description: "Group 1 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: groupDates[0], - lastModifiedBy: "Test User 1", - createdDate: groupDates[0], - createdBy: "Test User 1", - version: 1, - members: [ - new MemberModel( - 1, - "Test User 1", - 1, - MemberStatusEnum.ACTIVE, - new Date().toString(), - null, - ), - ], - }, - { - id: 2, - title: "Group 2", - description: "Group 2 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: groupDates[1], - lastModifiedBy: "Test User 2", - createdDate: groupDates[1], - createdBy: "Test User 2", - version: 1, - members: [ - new MemberModel( - 2, - "Test User 2", - 2, - MemberStatusEnum.ACTIVE, - new Date().toString(), - null, - ), - new MemberModel( - 3, - "Test User 3", - 2, - MemberStatusEnum.ACTIVE, - new Date().toString(), - null, - ), - new MemberModel( - 4, - "Test User 4", - 2, - MemberStatusEnum.ACTIVE, - new Date().toString(), - null, - ), - ], - }, - ]; + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); }); - it("should be created", () => { - expect(service).toBeTruthy(); + describe("get #groups", () => { + it("should return the current groups", () => { + expect(service.groups).toEqual([]); + }); }); - describe("#changeSort", () => { - it("should change the sortSource value", (done) => { - const expectedSorts = [ - GroupSortEnum.OLDEST, - GroupSortEnum.NEWEST, - GroupSortEnum.OLDEST, - ]; - const actualSorts: GroupSortEnum[] = []; - - const subscription = service.currentSort.subscribe((sort) => { - actualSorts.push(sort); - - if (actualSorts.length === expectedSorts.length) { - expect(actualSorts).toEqual(expectedSorts); - subscription.unsubscribe(); - done(); - } - }); + describe("set #groups", () => { + it("should set the groups", () => { + const group = createGroup(); + service.groups = [group]; - service.changeSort(GroupSortEnum.NEWEST); - service.changeSort(GroupSortEnum.OLDEST); + expect(service.groups).toEqual([group] as any); }); }); - describe("#sortGroups", () => { - it("should sort groups by created date ascending", () => { - service.changeSort(GroupSortEnum.OLDEST); - service.sortGroups(groups); - - expect(groups[0].id).toBe(1); - expect(groups[1].id).toBe(2); + describe("get #groups$", () => { + it("should allow the current groups to be observed", () => { + const groups = [createGroup(), createGroup()]; + + testScheduler.run(({ expectObservable }) => { + expectObservable(service.groups$).toBe("(abc)", { + a: [], + b: [groups[0]], + c: [groups[1]], + }); + + const groupsToEmit = of([groups[0]], [groups[1]]).pipe( + tap((groups) => (service.groups = groups)), + ); + + expectObservable(groupsToEmit).toBe("(ab|)", { + a: [groups[0]], + b: [groups[1]], + }); + }); }); + }); - it("should sort groups by created date descending", () => { - service.changeSort(GroupSortEnum.NEWEST); - service.sortGroups(groups); + describe("#handleGroupUpdate", () => { + it("should add a group if it doesn't exist and sort its members", () => { + const group = createGroup(); - expect(groups[0].id).toBe(2); - expect(groups[1].id).toBe(1); - }); + spyOn(groupSortingService, "sortMembers").and.returnValue([]); - it("should sort groups by current size ascending", () => { - service.changeSort(GroupSortEnum.LEAST_MEMBERS); - service.sortGroups(groups); + service.handleGroupUpdate(group); - expect(groups[0].id).toBe(1); - expect(groups[1].id).toBe(2); + expect(service.groups).toEqual([group]); + expect(groupSortingService.sortMembers).toHaveBeenCalledOnceWith([]); }); - it("should sort groups by current size descending", () => { - service.changeSort(GroupSortEnum.MOST_MEMBERS); - service.sortGroups(groups); + it("should replace a group if it exists", () => { + let group = createGroup([], { title: "Old Title" }); - expect(groups[0].id).toBe(2); - expect(groups[1].id).toBe(1); - }); - }); + service.handleGroupUpdate(group); - describe("#updateGroup", () => { - it("should remove the group when its status becomes non-active", () => { - const updatedGroup: GroupModel = { - id: 1, - title: "Group 1", - description: "Group 1 description", - status: GroupStatusEnum.AUTO_DISBANDED, - maxGroupSize: 10, - lastModifiedDate: new Date().toString(), - lastModifiedBy: "Test User 1", - createdDate: groupDates[0], - createdBy: "Test User 1", - version: 2, - members: [], - }; - service.updateGroup(updatedGroup, groups); - - expect(groups).not.toContain(updatedGroup); - }); + group = { ...group, title: "New Title" }; - it("should update group lastActive", () => { - const updatedGroup: GroupModel = { - id: 1, - title: "Group 1", - description: "Group 1 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: new Date().toString(), - lastModifiedBy: "Test User 1", - createdDate: groupDates[0], - createdBy: "Test User 1", - version: 2, - members: [], - }; - groups = service.updateGroup(updatedGroup, groups); - - expect(groups[0].lastModifiedDate).toBe(updatedGroup.lastModifiedDate); - }); + service.handleGroupUpdate(group); - it("should update group currentGroupSize", () => { - const member: MemberModel = { - id: 1, - username: "Test User 1", - groupId: 1, - memberStatus: MemberStatusEnum.ACTIVE, - joinedDate: new Date().toString(), - exitedDate: null, - }; - service.addMember(member, groups[0]); - - expect(groups[0].members.length).toBe(1); + expect(service.groups).toEqual([group]); }); - }); - describe("#shouldResortAfterSizeChange", () => { - it("should return true when sort is LEAST_MEMBERS", () => { - service.changeSort(GroupSortEnum.LEAST_MEMBERS); - expect(service.shouldResortAfterSizeChange()).toBe(true); - }); + it("should remove a group if it is not active", () => { + let group = createGroup(); - it("should return true when sort is MOST_MEMBERS", () => { - service.changeSort(GroupSortEnum.MOST_MEMBERS); - expect(service.shouldResortAfterSizeChange()).toBe(true); - }); + service.handleGroupUpdate(group); - it("should return false when sort is OLDEST", () => { - service.changeSort(GroupSortEnum.OLDEST); - expect(service.shouldResortAfterSizeChange()).toBe(false); - }); + group = { ...group, status: GroupStatusEnum.AUTO_DISBANDED }; - it("should return false when sort is NEWEST", () => { - service.changeSort(GroupSortEnum.NEWEST); - expect(service.shouldResortAfterSizeChange()).toBe(false); - }); + service.handleGroupUpdate(group); - it("should return false when sort is invalid", () => { - service.changeSort("INVALID" as GroupSortEnum); - expect(service.shouldResortAfterSizeChange()).toBe(false); + expect(service.groups).toEqual([]); }); - }); - describe("#removeGroup", () => { - it("should remove group from list", () => { - service.removeGroup(groups[0].id, groups); + describe("when the sort type is OLDEST or unrecognized", () => { + it("should add the group to the end of the list", () => { + const sortTypes: GroupSortEnum[] = [ + GroupSortEnum.OLDEST, + "UNKNOWN" as GroupSortEnum, + ]; - expect(groups.length).toBe(1); - expect(groups.map((group) => group.id)).toEqual([2]); - }); + for (const sortType of sortTypes) { + groupSortingService.changeSort = sortType; - it("should not remove group from list when group does not exist", () => { - service.removeGroup(3, groups); + service.groups = [createGroup(), createGroup()]; - expect(groups.length).toBe(2); - expect(groups.map((group) => group.id)).toEqual([1, 2]); - }); - }); + const group = createGroup(); + + service.handleGroupUpdate(group); - describe("#insertGroup", () => { - let group: GroupModel; - beforeEach(() => { - group = { - id: 3, - title: "Group 3", - description: "Group 3 description", - status: GroupStatusEnum.ACTIVE, - maxGroupSize: 10, - lastModifiedDate: new Date().toString(), - lastModifiedBy: "Test User 3", - createdDate: new Date().toString(), - createdBy: "Test User 3", - version: 1, - members: [ - new MemberModel( - 5, - "Test User 5", - 3, - MemberStatusEnum.ACTIVE, - new Date().toString(), - null, - ), - new MemberModel( - 6, - "Test User 6", - 3, - MemberStatusEnum.ACTIVE, - new Date().toString(), - null, - ), - ], - }; + expect(service.groups.length).toEqual(3); + expect(service.groups[2]).toEqual(group); + } + }); }); - it("should only insert group at end of list when sort is OLDEST", () => { - service.changeSort(GroupSortEnum.OLDEST); - service.sortGroups(groups); - service.insertGroup(group, groups); + describe("when the sort type is NEWEST", () => { + it("should add the group to the beginning of the list", () => { + groupSortingService.changeSort = GroupSortEnum.NEWEST; - expect(groups.length).toBe(3); - expect(groups.map((group) => group.id)).toEqual([1, 2, 3]); - }); + service.groups = [createGroup(), createGroup()]; + + const group = createGroup(); - it("should only insert group at start of list when sort is NEWEST", () => { - service.changeSort(GroupSortEnum.NEWEST); - service.sortGroups(groups); - service.insertGroup(group, groups); + service.handleGroupUpdate(group); - expect(groups.length).toBe(3); - expect(groups.map((group) => group.id)).toEqual([3, 2, 1]); + expect(service.groups.length).toEqual(3); + expect(service.groups[0]).toEqual(group); + }); }); - it("should only insert group at correct index when sort is LEAST_MEMBERS", () => { - service.changeSort(GroupSortEnum.LEAST_MEMBERS); - service.sortGroups(groups); - service.insertGroup(group, groups); + describe("when the sort type is MOST_MEMBERS", () => { + beforeEach(() => { + groupSortingService.changeSort = GroupSortEnum.MOST_MEMBERS; + }); + + it("should add the group before the first group with less members than it", () => { + service.groups = [ + createGroup(createMembers(3)), + createGroup(createMembers(1)), + ]; - expect(groups.length).toBe(3); - expect(groups.map((group) => group.id)).toEqual([1, 3, 2]); + const group = createGroup(createMembers(2)); + + service.handleGroupUpdate(group); + + expect(service.groups.length).toEqual(3); + expect(service.groups[1]).toEqual(group); + }); + + it("should add the group to the end of the list if no other group has less members than it", () => { + service.groups = [ + createGroup(createMembers(3)), + createGroup(createMembers(2)), + ]; + + const group = createGroup(createMembers(1)); + + service.handleGroupUpdate(group); + + expect(service.groups.length).toEqual(3); + expect(service.groups[2]).toEqual(group); + }); }); - it("should only insert group at correct index when sort is MOST_MEMBERS", () => { - service.changeSort(GroupSortEnum.MOST_MEMBERS); - service.sortGroups(groups); - group.maxGroupSize = 15; - service.insertGroup(group, groups); + describe("when the sort type is LEAST_MEMBERS", () => { + beforeEach(() => { + groupSortingService.changeSort = GroupSortEnum.LEAST_MEMBERS; + }); + + it("should add the group before the first group with more members than it", () => { + service.groups = [ + createGroup(createMembers(1)), + createGroup(createMembers(3)), + ]; + + const group = createGroup(createMembers(2)); + + service.handleGroupUpdate(group); + + expect(service.groups.length).toEqual(3); + expect(service.groups[1]).toEqual(group); + }); + + it("should add the group to the end of the list if no other group has more members than it", () => { + service.groups = [ + createGroup(createMembers(1)), + createGroup(createMembers(2)), + ]; + + const group = createGroup(createMembers(3)); + + service.handleGroupUpdate(group); - expect(groups.length).toBe(3); - expect(groups.map((group) => group.id)).toEqual([2, 3, 1]); + expect(service.groups.length).toEqual(3); + expect(service.groups[2]).toEqual(group); + }); }); - it("should only insert group at end of list when sort is invalid", () => { - service.changeSort("INVALID" as GroupSortEnum); - service.sortGroups(groups); - service.insertGroup(group, groups); + describe("adding a member", () => { + it("should add a member to the group", () => { + const members = createMembers(2); + + service.groups = [createGroup([]), createGroup(members)]; + + service.addMember(members[1], 2); + + expect(service.groups[1].members).toEqual(members); + }); + + it("should not add a member if the group does not exist", () => { + const members = createMembers(2); + + const groups = [createGroup([]), createGroup(members[0])]; + + service.groups = groups; + + service.addMember(members[1], 3); + + expect(groups.length).toEqual(2); + + for (let i = 0; i < groups.length; i++) { + expect(service.groups[i].members).toEqual(groups[i].members); + } + }); + + it("should not add a member if the member is already in the group", () => { + const members = createMembers(2); + + service.groups = [createGroup([members[0]]), createGroup([members[1]])]; + + service.addMember(members[0], 1); + + expect(service.groups[0].members).toEqual([members[0]]); + }); + + it("should sort the groups after adding a member if the sort type is MOST_MEMBERS or LEAST_MEMBERS", () => { + const sortTypes: GroupSortEnum[] = [ + GroupSortEnum.MOST_MEMBERS, + GroupSortEnum.LEAST_MEMBERS, + ]; + + spyOn(groupSortingService, "sortGroups"); + + for (const sortType of sortTypes) { + groupSortingService.changeSort = sortType; + + const member = createMembers(1)[0]; + service.groups = [createGroup()]; - expect(groups.length).toBe(3); - expect(groups.map((group) => group.id)).toEqual([1, 2, 3]); + service.addMember(member, groupIdCounter); + } + + expect(groupSortingService.sortGroups).toHaveBeenCalledTimes(2); + }); }); - it("should only not insert group when group already exists", () => { - service.changeSort(GroupSortEnum.OLDEST); - service.sortGroups(groups); - service.insertGroup(groups[0], groups); + describe("removing a member", () => { + it("should remove a member from the group", () => { + const members = createMembers(2); + + service.groups = [createGroup(members), createGroup([members[0]])]; + + service.removeMember(members[1].id, 1); + + expect(service.groups[0].members).toEqual([members[0]]); + }); + + it("should not remove a member if the group does not exist", () => { + const members = createMembers(2); + + const groups = [createGroup(members), createGroup(members[0])]; + + service.groups = groups; + + service.removeMember(members[1].id, 3); + + for (let i = 0; i < groups.length; i++) { + expect(service.groups[i].members).toEqual(groups[i].members); + } + }); + + it("should not remove a member if the member is not in the group", () => { + const members = createMembers(2); + + service.groups = [createGroup([members[0]]), createGroup([members[0]])]; + + service.removeMember(members[1].id, 1); + + expect(service.groups[0].members).toEqual([members[0]]); + expect(service.groups[1].members).toEqual([members[0]]); + }); + + it("should sort the groups after removing a member if the sort type is MOST_MEMBERS or LEAST_MEMBERS", () => { + const sortTypes: GroupSortEnum[] = [ + GroupSortEnum.MOST_MEMBERS, + GroupSortEnum.LEAST_MEMBERS, + ]; - expect(groups.length).toBe(2); - expect(groups.map((group) => group.id)).toEqual([1, 2]); + spyOn(groupSortingService, "sortGroups"); + + for (const sortType of sortTypes) { + groupSortingService.changeSort = sortType; + + const member = createMembers(1); + service.groups = [createGroup(member)]; + + service.removeMember(member[0].id, groupIdCounter); + } + + expect(groupSortingService.sortGroups).toHaveBeenCalledTimes(2); + }); }); }); }); diff --git a/src/app/groups/services/groups.service.ts b/src/app/groups/services/groups.service.ts index ca9ad20..f2cc0d8 100644 --- a/src/app/groups/services/groups.service.ts +++ b/src/app/groups/services/groups.service.ts @@ -1,174 +1,171 @@ import { Injectable } from "@angular/core"; -import { BehaviorSubject } from "rxjs"; import { GroupModel } from "../../model/group.model"; import { GroupSortEnum } from "../../model/enums/groupSort.enum"; import { MemberModel } from "../../model/member.model"; import { GroupStatusEnum } from "../../model/enums/groupStatus.enum"; +import { BehaviorSubject } from "rxjs"; +import { GroupSortingService } from "./groupSorting.service"; @Injectable({ providedIn: "root", }) export class GroupsService { - private readonly sortSource = new BehaviorSubject( - GroupSortEnum.OLDEST, - ); - private readonly _currentSort = this.sortSource.asObservable(); + private readonly _groups$ = new BehaviorSubject([]); + + constructor(private readonly groupSortingService: GroupSortingService) {} + + get groups$() { + return this._groups$.asObservable(); + } + + get groups() { + return this._groups$.getValue(); + } - get currentSort() { - return this._currentSort; + set groups(groups: GroupModel[]) { + this._groups$.next(groups); } - changeSort(sort: GroupSortEnum) { - this.sortSource.next(sort); + /** + * Adds or updates a group in the list of groups + * Currently, the backend sends the initial group with members, and all subsequent updates without members. + * So for any updates, we need to keep the old members and replace the group with the new group. + * Member updates are managed separately via the addMember and removeMember methods as a result of + * receiving member joined and member left events. + * + * @param group The new or updated group + * @private + */ + public handleGroupUpdate(group: GroupModel): GroupModel[] { + const index = this.groups.findIndex( + (groupInList) => group.id === groupInList.id, + ); + + if (group.status !== GroupStatusEnum.ACTIVE) { + console.debug("Group not active, removing group", group); + return this.removeGroup(group.id); + } else if (index === -1) { + console.debug("Group not found, adding group", group); + this.groupSortingService.sortMembers(group.members); + this.addGroup(group); + } else { + console.debug("Group found, replacing", group); + const oldGroup: GroupModel = this.groups[index]; + this.groups.splice(index, 1, { ...group, members: oldGroup.members }); + } + + return this.groups; } - sortGroups(groups: GroupModel[]) { - switch (this.sortSource.getValue()) { + private addGroup(groupToAdd: GroupModel) { + const groupExists = this.groups.find((group) => group.id === groupToAdd.id); + + if (groupExists) { + console.warn("Group already exists in list, not adding group"); + return false; + } + + switch (this.groupSortingService.currentSort) { case GroupSortEnum.OLDEST: - console.debug("Sorting by oldest"); - groups = this.sortGroupsByCreatedDate(groups, true); + this.groups.push(groupToAdd); break; case GroupSortEnum.NEWEST: - console.debug("Sorting by newest"); - groups = this.sortGroupsByCreatedDate(groups, false); + this.groups.unshift(groupToAdd); + break; + case GroupSortEnum.LEAST_MEMBERS: + this.insertGroupByMemberCount(groupToAdd, GroupSortEnum.LEAST_MEMBERS); break; case GroupSortEnum.MOST_MEMBERS: - console.debug("Sorting by most members"); - groups = this.sortGroupsByCurrentGroupSize(groups, false); + this.insertGroupByMemberCount(groupToAdd, GroupSortEnum.MOST_MEMBERS); break; - case GroupSortEnum.LEAST_MEMBERS: - console.debug("Sorting by least members"); - groups = this.sortGroupsByCurrentGroupSize(groups, true); + default: + console.warn("Unrecognized sort type, adding group to end of list"); + this.groups.push(groupToAdd); break; } - return groups; - } - - private sortGroupsByCreatedDate(groups: GroupModel[], reverse: boolean) { - return groups.sort((a, b) => { - const aDate = new Date(a.createdDate); - const bDate = new Date(b.createdDate); - const difference = bDate.getTime() - aDate.getTime(); - return reverse ? -difference : difference; - }); + return true; } - private sortGroupsByCurrentGroupSize(groups: GroupModel[], reverse: boolean) { - return groups.sort((a, b) => { - const order = b.members.length - a.members.length; - return reverse ? -order : order; - }); - } + private insertGroupByMemberCount( + group: GroupModel, + sortType: GroupSortEnum.LEAST_MEMBERS | GroupSortEnum.MOST_MEMBERS, + ) { + const index = + sortType === GroupSortEnum.LEAST_MEMBERS + ? this.findFirstGroupWithMoreMembers(group) + : this.findFirstGroupWithLessMembers(group); - updateGroup(updatedGroup: GroupModel, groups: GroupModel[]): GroupModel[] { - if (updatedGroup.status !== GroupStatusEnum.ACTIVE) { - return groups.filter((group) => group.id !== updatedGroup.id); + if (index != -1) { + this.groups.splice(index, 0, group); } else { - return groups.map((group) => - group.id === updatedGroup.id - ? { ...updatedGroup, members: group.members } - : group, - ); + this.groups.push(group); } } - addMember(member: MemberModel, group: GroupModel) { - const isMemberInGroup = group.members.find( - (memberInGroup) => memberInGroup.id === member.id, + private findFirstGroupWithMoreMembers(group: GroupModel): number { + return this.groups.findIndex( + (groupInList) => groupInList.members.length > group.members.length, ); - if (isMemberInGroup) { - return false; - } else { - group.members.push(member); - return true; - } } - removeMember(memberId: number, group: GroupModel) { - console.debug("Current members: ", group.members); - const index = group.members.findIndex((member) => member.id === memberId); - if (index !== -1) { - console.debug("Removing member from group"); - group.members.splice(index, 1); - return true; - } - return false; + private findFirstGroupWithLessMembers(group: GroupModel): number { + return this.groups.findIndex( + (groupInList) => groupInList.members.length < group.members.length, + ); } - shouldResortAfterSizeChange(): boolean { - const sortCriteria = this.sortSource.getValue(); - return ( - sortCriteria === GroupSortEnum.MOST_MEMBERS || - sortCriteria === GroupSortEnum.LEAST_MEMBERS - ); + private removeGroup(groupId: number) { + return (this.groups = this.groups.filter((group) => group.id !== groupId)); } - removeGroup(groupId: number, groups: GroupModel[]): boolean { - const index = groups.findIndex((group) => group.id === groupId); - if (index !== -1) { - groups.splice(index, 1); - return true; + public addMember(member: MemberModel, groupId: number) { + const group = this.findGroup(groupId); + if (!group) return; + + const memberIndex = this.findMemberIndex(group, member.id); + + if (memberIndex != -1) { + console.warn("Cannot add member: already exists in group"); + return; + } + + group.members.push(member); + + if (this.groupSortingService.shouldSortGroupAfterSizeChange()) { + this.groupSortingService.sortGroups(this.groups); } - return false; } - insertGroup(groupToAdd: GroupModel, groups: GroupModel[]) { - const groupExists = groups.find((group) => group.id === groupToAdd.id); + public removeMember(memberId: number, groupId: number) { + const group = this.findGroup(groupId); + if (!group) return; - if (groupExists) { - console.debug("Group already exists"); - return false; + const memberIndex = this.findMemberIndex(group, memberId); + if (memberIndex == -1) { + console.warn("Cannot remove member: member not found in group"); + return; } - switch (this.sortSource.getValue()) { - case GroupSortEnum.OLDEST: - console.debug("pushing group to end of list"); - groups.push(groupToAdd); - break; - case GroupSortEnum.NEWEST: - console.debug("unshifting group to start of list"); - groups.unshift(groupToAdd); - break; - case GroupSortEnum.LEAST_MEMBERS: { - const largerGroupIndex = groups.findIndex( - (groupInList) => - groupInList.members.length > groupToAdd.members.length, - ); - console.debug("largerGroupIndex: ", largerGroupIndex); - this.insertUsingIndex(largerGroupIndex, groupToAdd, groups); - break; - } - case GroupSortEnum.MOST_MEMBERS: { - const smallerGroupIndex = groups.findIndex( - (groupInList) => - groupInList.members.length < groupToAdd.members.length, - ); - console.debug("smallerGroupIndex: ", smallerGroupIndex); - this.insertUsingIndex(smallerGroupIndex, groupToAdd, groups); - break; - } - default: - console.debug("default: pushing group to end of list"); - groups.push(groupToAdd); - break; + group.members.splice(memberIndex, 1); + + if (this.groupSortingService.shouldSortGroupAfterSizeChange()) { + this.groupSortingService.sortGroups(this.groups); } - return true; } - private insertUsingIndex( - index: number, - group: GroupModel, - groups: GroupModel[], - ) { - if (index === -1) { - if (this.sortSource.getValue() === GroupSortEnum.LEAST_MEMBERS) { - groups.push(group); - } else { - groups.unshift(group); - } + private findGroup(groupId: number) { + const group = this.groups.find((group) => group.id === groupId); + + if (!group) { + console.warn("Cannot find group: Group not found"); + return null; } else { - groups.splice(index, 0, group); + return group; } } + + private findMemberIndex(group: GroupModel, memberId: number) { + return group.members.findIndex((member) => member.id === memberId); + } } diff --git a/src/app/groups/services/stateUpdate.service.spec.ts b/src/app/groups/services/stateUpdate.service.spec.ts new file mode 100644 index 0000000..61824de --- /dev/null +++ b/src/app/groups/services/stateUpdate.service.spec.ts @@ -0,0 +1,170 @@ +import { TestBed } from "@angular/core/testing"; +import { StateUpdateService } from "./stateUpdate.service"; +import { GroupsService } from "./groups.service"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { PublicEventModel } from "../../model/events/publicEvent.model"; +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; +import { StateEnum } from "../../services/state/StateEnum"; +import { finalize, of, tap } from "rxjs"; + +describe("StateUpdateService", () => { + let service: StateUpdateService; + let groupService: GroupsService; + let event: jasmine.SpyObj; + let visitor: jasmine.SpyObj; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [StateUpdateService], + }); + + service = TestBed.inject(StateUpdateService); + groupService = TestBed.inject(GroupsService); + + event = jasmine.createSpyObj("PublicEventModel", ["accept"]); + visitor = jasmine.createSpyObj("EventVisitor", ["visitPublicEvent"]); + }); + + describe("observables", () => { + let testScheduler: TestScheduler; + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + it("should allow the request and component states to be observed", () => { + testScheduler.run(({ cold, expectObservable }) => { + const updateObservable = of( + StateEnum.LOADING, + StateEnum.REQUESTING, + ).pipe(tap((state) => service.handleNewRequestState(state))); + + const eventObservable = cold("5ms |").pipe( + finalize(() => service.handleEventAndUpdateStates(event, visitor)), + ); + + expectObservable(service.requestState$).toBe("(a b c) d", { + a: StateEnum.INITIALIZING, + b: StateEnum.LOADING, + c: StateEnum.REQUESTING, + d: StateEnum.READY, + }); + + expectObservable(service.componentState$).toBe("(a) -- b", { + a: StateEnum.INITIALIZING, + b: StateEnum.READY, + }); + + expectObservable(updateObservable).toBe("(b c |)", { + b: StateEnum.LOADING, + c: StateEnum.REQUESTING, + }); + + expectObservable(eventObservable).toBe("5ms |"); + }); + }); + }); + + describe("#handleEventAndUpdateStates", () => { + it("should set groups to empty array if request state is not ready", () => { + groupService.groups = [{}, {}] as any; + + service.handleEventAndUpdateStates(event, visitor); + + expect(groupService.groups).toEqual([]); + }); + + it("should set request and component states to ready if request state is not ready", () => { + service.handleEventAndUpdateStates(event, visitor); + + expect(service.requestState).toEqual(StateEnum.READY); + expect(service.componentState).toEqual(StateEnum.READY); + }); + + it("should call event.accept with eventVisitor on the given event", () => { + service.handleEventAndUpdateStates(event, visitor); + + expect(event.accept).toHaveBeenCalledWith(visitor); + }); + + it("should call event.accept with eventVisitor on the given event after setting groups to empty and before updating states", () => { + groupService.groups = [{}, {}] as any; + event.accept.and.callFake(() => { + throw new Error("This error is meant to stop further execution"); + }); + + expect(() => + service.handleEventAndUpdateStates(event, visitor), + ).toThrowError("This error is meant to stop further execution"); + expect(groupService.groups).toEqual([]); + expect(service.requestState).toEqual(StateEnum.INITIALIZING); + expect(service.componentState).toEqual(StateEnum.INITIALIZING); + }); + }); + + describe("#handleNewRequestState", () => { + let allStates: StateEnum[]; + beforeEach(() => { + allStates = [ + StateEnum.INITIALIZING, + StateEnum.LOADING, + StateEnum.REQUESTING, + StateEnum.READY, + StateEnum.RETRYING, + StateEnum.DORMANT, + StateEnum.EVENT_PROCESSED, + StateEnum.EVENT_PROCESSING_TIMEOUT, + StateEnum.REQUEST_ACCEPTED, + StateEnum.REQUEST_COMPLETED, + StateEnum.REQUEST_REJECTED, + StateEnum.REQUEST_TIMEOUT, + ]; + }); + + describe("request state behavior", () => { + it("should set request state to the given state if the given state is not ready", () => { + allStates.forEach((state) => { + if (state !== StateEnum.READY) { + service.handleNewRequestState(state); + expect(service.requestState).toEqual(state); + } + }); + }); + }); + + describe("component state behavior", () => { + it("should not change component state if it's ready", () => { + service.handleEventAndUpdateStates(event, visitor); + expect(service.componentState).toEqual(StateEnum.READY); + + allStates.forEach((state) => { + service.handleNewRequestState(state); + expect(service.componentState).toEqual(StateEnum.READY); + }); + }); + + it("should set component state to retrying if the request state is retrying", () => { + service.handleNewRequestState(StateEnum.RETRYING); + expect(service.componentState).toEqual(StateEnum.RETRYING); + }); + + it("should set component state to loading if it's not initializing", () => { + service.handleNewRequestState(StateEnum.RETRYING); + expect(service.componentState).toEqual(StateEnum.RETRYING); + + service.handleNewRequestState(StateEnum.LOADING); + expect(service.componentState).toEqual(StateEnum.LOADING); + }); + + it("should not set component state to loading if it's currently initializing", () => { + service.handleNewRequestState(StateEnum.INITIALIZING); + expect(service.componentState).toEqual(StateEnum.INITIALIZING); + + service.handleNewRequestState(StateEnum.LOADING); + expect(service.componentState).toEqual(StateEnum.INITIALIZING); + }); + }); + }); +}); diff --git a/src/app/groups/services/stateUpdate.service.ts b/src/app/groups/services/stateUpdate.service.ts new file mode 100644 index 0000000..ca34d4e --- /dev/null +++ b/src/app/groups/services/stateUpdate.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { StateEnum } from "../../services/state/StateEnum"; +import { PublicEventModel } from "../../model/events/publicEvent.model"; +import { GroupsService } from "./groups.service"; +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; + +@Injectable({ + providedIn: "root", +}) +export class StateUpdateService { + private readonly _requestState$: BehaviorSubject = + new BehaviorSubject(StateEnum.INITIALIZING); + private _componentState$: BehaviorSubject = + new BehaviorSubject(StateEnum.INITIALIZING); + + constructor(private readonly groupService: GroupsService) {} + + get requestState$() { + return this._requestState$.asObservable(); + } + + get requestState() { + return this._requestState$.getValue(); + } + + get componentState$() { + return this._componentState$.asObservable(); + } + + get componentState() { + return this._componentState$.getValue(); + } + + public handleEventAndUpdateStates( + event: PublicEventModel, + eventVisitor: EventVisitor, + ) { + if (this._requestState$.getValue() !== StateEnum.READY) { + this.groupService.groups = []; // TODO: Would need to make this group-agnostic if class ever used outside of groups + } + + event.accept(eventVisitor); + + if (this._requestState$.getValue() !== StateEnum.READY) { + this._requestState$.next(StateEnum.READY); + this._componentState$.next(StateEnum.READY); + } + } + + public handleNewRequestState(state: StateEnum) { + if (state !== StateEnum.READY) { + this._requestState$.next(state); + this.componentStateMapper(state); + } + } + + private componentStateMapper(status: StateEnum) { + if (this._componentState$.getValue() === StateEnum.READY) { + return; + } + + let mappedStatus: StateEnum; + + switch (status) { + case StateEnum.READY: + mappedStatus = StateEnum.READY; + break; + case StateEnum.LOADING: + case StateEnum.REQUESTING: + mappedStatus = + this._componentState$.getValue() === StateEnum.INITIALIZING + ? StateEnum.INITIALIZING + : StateEnum.LOADING; + break; + case StateEnum.RETRYING: + mappedStatus = StateEnum.RETRYING; + break; + default: + mappedStatus = this._componentState$.getValue(); + } + + if (mappedStatus !== this._componentState$.getValue()) { + this._componentState$.next(mappedStatus); + } + } +} diff --git a/src/app/groups/wrapper/groups.component.spec.ts b/src/app/groups/wrapper/groups.component.spec.ts index 2e4aa84..0c75ede 100644 --- a/src/app/groups/wrapper/groups.component.spec.ts +++ b/src/app/groups/wrapper/groups.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { GroupsComponent } from "./groups.component"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Component } from "@angular/core"; +import { ConfigService } from "../../config/config.service"; @Component({ selector: "app-group-utility-bar", @@ -21,9 +22,10 @@ describe("GroupsComponent", () => { let fixture: ComponentFixture; let groupsComponent: GroupsComponent; - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [NoopAnimationsModule, GroupsComponent], + providers: [{ provide: ConfigService, useValue: {} }], }) .overrideComponent(GroupsComponent, { set: { diff --git a/src/app/model/enums/eventType.enum.ts b/src/app/model/enums/eventType.enum.ts index 566d976..b7cbf42 100644 --- a/src/app/model/enums/eventType.enum.ts +++ b/src/app/model/enums/eventType.enum.ts @@ -4,7 +4,8 @@ export enum EventTypeEnum { GROUP_CREATED = "GROUP_CREATED", GROUP_UPDATED = "GROUP_UPDATED", - GROUP_DISBANDED = "GROUP_DISBANDED", + GROUP_DISBANDED = "GROUP_DISBANDED", // Currently unused by server MEMBER_JOINED = "MEMBER_JOINED", MEMBER_LEFT = "MEMBER_LEFT", + NONE = "NONE", } diff --git a/src/app/model/enums/states.enum.ts b/src/app/model/enums/states.enum.ts deleted file mode 100644 index a6ec588..0000000 --- a/src/app/model/enums/states.enum.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum StatesEnum { - LOADING = "LOADING", - READY = "READY", - NEUTRAL = "NEUTRAL", - HTTP_INTERNAL_SERVER_ERROR = "HTTP_INTERNAL_SERVER_ERROR", - WEB_SOCKET_CONNECTION_ERROR = "WEB_SOCKET_CONNECTION_ERROR", -} diff --git a/src/app/model/errorData.model.ts b/src/app/model/errorData.model.ts new file mode 100644 index 0000000..79a416d --- /dev/null +++ b/src/app/model/errorData.model.ts @@ -0,0 +1,5 @@ +import { EventDataModel } from "./events/eventDataModel"; + +export class ErrorDataModel implements EventDataModel { + constructor(public error: string) {} +} diff --git a/src/app/model/events/event.revivable.spec.ts b/src/app/model/events/event.revivable.spec.ts new file mode 100644 index 0000000..44a9f71 --- /dev/null +++ b/src/app/model/events/event.revivable.spec.ts @@ -0,0 +1,50 @@ +import { PrivateEventModel } from "./privateEvent.model"; +import { EventRevivable } from "./event.revivable"; +import { PublicEventModel } from "./publicEvent.model"; +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; + +describe("EventRevivable", () => { + let visitorSpy: EventVisitor; + + beforeEach(() => { + visitorSpy = { + visitPrivateEvent: jasmine.createSpy("visitPrivateEvent"), + visitPublicEvent: jasmine.createSpy("visitPublicEvent"), + }; + }); + + describe("private event", () => { + it("should create an instance of private event objects", () => { + const privateEvent: Partial = { + eventId: "eventId", + websocketId: "websocketId", + }; + const result = EventRevivable.createEvent(privateEvent); + + expect(result).toBeInstanceOf(PrivateEventModel); + + result.accept(visitorSpy); + + expect(visitorSpy.visitPrivateEvent).toHaveBeenCalledWith( + result as PrivateEventModel, + ); + }); + }); + + describe("public event", () => { + it("should create an instance of public event objects", () => { + const publicEvent: Partial = { + eventId: "eventId", + }; + const result = EventRevivable.createEvent(publicEvent); + + expect(result).toBeInstanceOf(PublicEventModel); + + result.accept(visitorSpy); + + expect(visitorSpy.visitPublicEvent).toHaveBeenCalledWith( + result as PublicEventModel, + ); + }); + }); +}); diff --git a/src/app/model/events/event.revivable.ts b/src/app/model/events/event.revivable.ts new file mode 100644 index 0000000..5789237 --- /dev/null +++ b/src/app/model/events/event.revivable.ts @@ -0,0 +1,47 @@ +import { PublicEventModel } from "./publicEvent.model"; +import { PrivateEventModel } from "./privateEvent.model"; + +/** + * Helper class to transform events deserialized form the backend into an actual + * class instance. This is necessary for the visitor pattern to work, as the backend + * does not have the accept method on its events. + */ +export class EventRevivable { + // TODO: This should be updated to use a discriminant type, but that would require changes to group-sync + public static createEvent(event: any): PublicEventModel | PrivateEventModel { + if ("websocketId" in event) { + return EventRevivable.createPrivateEvent(event); + } else { + return EventRevivable.createPublicEvent(event); + } + } + + private static createPublicEvent( + publicEvent: PublicEventModel, + ): PublicEventModel { + return new PublicEventModel( + publicEvent.eventId, + publicEvent.aggregateId, + publicEvent.aggregateType, + publicEvent.eventType, + publicEvent.eventData, + publicEvent.eventStatus, + publicEvent.createdDate, + ); + } + + private static createPrivateEvent( + privateEvent: PrivateEventModel, + ): PrivateEventModel { + return new PrivateEventModel( + privateEvent.eventId, + privateEvent.aggregateId, + privateEvent.websocketId, + privateEvent.aggregateType, + privateEvent.eventType, + privateEvent.eventData, + privateEvent.eventStatus, + privateEvent.createdDate, + ); + } +} diff --git a/src/app/model/events/event.ts b/src/app/model/events/event.ts new file mode 100644 index 0000000..82caa3e --- /dev/null +++ b/src/app/model/events/event.ts @@ -0,0 +1,17 @@ +import { AggregateTypeEnum } from "../enums/aggregateType.enum"; +import { EventTypeEnum } from "../enums/eventType.enum"; +import { EventDataModel } from "./eventDataModel"; +import { EventStatusEnum } from "../enums/eventStatus.enum"; +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; + +export interface Event { + eventId: string; + aggregateId: number; + aggregateType: AggregateTypeEnum; + eventType: EventTypeEnum; + eventData: EventDataModel; + eventStatus: EventStatusEnum; + createdDate: string; + + accept(visitor: EventVisitor): void; +} diff --git a/src/app/model/eventDataModel.ts b/src/app/model/events/eventDataModel.ts similarity index 100% rename from src/app/model/eventDataModel.ts rename to src/app/model/events/eventDataModel.ts diff --git a/src/app/model/events/privateEvent.model.spec.ts b/src/app/model/events/privateEvent.model.spec.ts new file mode 100644 index 0000000..36c8c69 --- /dev/null +++ b/src/app/model/events/privateEvent.model.spec.ts @@ -0,0 +1,32 @@ +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; +import { AggregateTypeEnum } from "../enums/aggregateType.enum"; +import { EventTypeEnum } from "../enums/eventType.enum"; +import { EventStatusEnum } from "../enums/eventStatus.enum"; +import { PrivateEventModel } from "./privateEvent.model"; + +describe("PrivateEventModel", () => { + let eventVisitor: jasmine.SpyObj; + let privateEvent: PrivateEventModel; + + beforeEach(() => { + eventVisitor = jasmine.createSpyObj("EventVisitor", ["visitPrivateEvent"]); + privateEvent = new PrivateEventModel( + "eventId", + 1, + "websocketId", + AggregateTypeEnum.GROUP, + EventTypeEnum.GROUP_CREATED, + {} as any, + EventStatusEnum.SUCCESSFUL, + "createdDate", + ); + }); + + describe("#accept", () => { + it("should call the visitor's visitPrivateEvent method", () => { + privateEvent.accept(eventVisitor); + + expect(eventVisitor.visitPrivateEvent).toHaveBeenCalledWith(privateEvent); + }); + }); +}); diff --git a/src/app/model/events/privateEvent.model.ts b/src/app/model/events/privateEvent.model.ts new file mode 100644 index 0000000..a525fc5 --- /dev/null +++ b/src/app/model/events/privateEvent.model.ts @@ -0,0 +1,23 @@ +import { AggregateTypeEnum } from "../enums/aggregateType.enum"; +import { EventTypeEnum } from "../enums/eventType.enum"; +import { EventStatusEnum } from "../enums/eventStatus.enum"; +import { EventDataModel } from "./eventDataModel"; +import { Event } from "./event"; +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; + +export class PrivateEventModel implements Event { + constructor( + public eventId: string, + public aggregateId: number, + public websocketId: string, + public aggregateType: AggregateTypeEnum, + public eventType: EventTypeEnum, + public eventData: EventDataModel, + public eventStatus: EventStatusEnum, + public createdDate: string, + ) {} + + accept(visitor: EventVisitor): void { + visitor.visitPrivateEvent(this); + } +} diff --git a/src/app/model/events/publicEvent.model.spec.ts b/src/app/model/events/publicEvent.model.spec.ts new file mode 100644 index 0000000..85bcf4b --- /dev/null +++ b/src/app/model/events/publicEvent.model.spec.ts @@ -0,0 +1,31 @@ +import { PublicEventModel } from "./publicEvent.model"; +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; +import { AggregateTypeEnum } from "../enums/aggregateType.enum"; +import { EventTypeEnum } from "../enums/eventType.enum"; +import { EventStatusEnum } from "../enums/eventStatus.enum"; + +describe("PublicEventModel", () => { + let eventVisitor: jasmine.SpyObj; + let publicEvent: PublicEventModel; + + beforeEach(() => { + eventVisitor = jasmine.createSpyObj("EventVisitor", ["visitPublicEvent"]); + publicEvent = new PublicEventModel( + "eventId", + 1, + AggregateTypeEnum.GROUP, + EventTypeEnum.GROUP_CREATED, + {} as any, + EventStatusEnum.SUCCESSFUL, + "createdDate", + ); + }); + + describe("#accept", () => { + it("should call the visitor's visitPublicEvent method", () => { + publicEvent.accept(eventVisitor); + + expect(eventVisitor.visitPublicEvent).toHaveBeenCalledWith(publicEvent); + }); + }); +}); diff --git a/src/app/model/events/publicEvent.model.ts b/src/app/model/events/publicEvent.model.ts new file mode 100644 index 0000000..8693d39 --- /dev/null +++ b/src/app/model/events/publicEvent.model.ts @@ -0,0 +1,34 @@ +import { AggregateTypeEnum } from "../enums/aggregateType.enum"; +import { EventTypeEnum } from "../enums/eventType.enum"; +import { EventStatusEnum } from "../enums/eventStatus.enum"; +import { EventDataModel } from "./eventDataModel"; +import { Event } from "./event"; +import { EventVisitor } from "../../services/notifications/visitors/eventVisitor"; + +export class PublicEventModel implements Event { + constructor( + public eventId: string, + public aggregateId: number, + public aggregateType: AggregateTypeEnum, + public eventType: EventTypeEnum, + public eventData: EventDataModel, + public eventStatus: EventStatusEnum, + public createdDate: string, + ) {} + + accept(visitor: EventVisitor): void { + visitor.visitPublicEvent(this); + } + + public static instantiate(publicEvent: PublicEventModel): PublicEventModel { + return new PublicEventModel( + publicEvent.eventId, + publicEvent.aggregateId, + publicEvent.aggregateType, + publicEvent.eventType, + publicEvent.eventData, + publicEvent.eventStatus, + publicEvent.createdDate, + ); + } +} diff --git a/src/app/model/group.model.ts b/src/app/model/group.model.ts index 2c44099..f864197 100644 --- a/src/app/model/group.model.ts +++ b/src/app/model/group.model.ts @@ -1,6 +1,6 @@ import { MemberModel } from "./member.model"; import { GroupStatusEnum } from "./enums/groupStatus.enum"; -import { EventDataModel } from "./eventDataModel"; +import { EventDataModel } from "./events/eventDataModel"; export class GroupModel implements EventDataModel { constructor( diff --git a/src/app/model/member.model.ts b/src/app/model/member.model.ts index 671fe46..faa7cb8 100644 --- a/src/app/model/member.model.ts +++ b/src/app/model/member.model.ts @@ -1,5 +1,5 @@ import { MemberStatusEnum } from "./enums/memberStatus.enum"; -import { EventDataModel } from "./eventDataModel"; +import { EventDataModel } from "./events/eventDataModel"; export class MemberModel implements EventDataModel { constructor( diff --git a/src/app/model/privateEvent.model.ts b/src/app/model/privateEvent.model.ts deleted file mode 100644 index 5115fb8..0000000 --- a/src/app/model/privateEvent.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { PublicEventModel } from "./publicEvent.model"; -import { AggregateTypeEnum } from "./enums/aggregateType.enum"; -import { EventTypeEnum } from "./enums/eventType.enum"; -import { EventStatusEnum } from "./enums/eventStatus.enum"; -import { EventDataModel } from "./eventDataModel"; - -export class PrivateEventModel extends PublicEventModel { - constructor( - public eventId: string, - public override aggregateId: number, - public websocketId: string, - public override aggregateType: AggregateTypeEnum, - public override eventType: EventTypeEnum, - public override eventData: string | EventDataModel, - public override eventStatus: EventStatusEnum, - public override createdDate: string, - ) { - super( - aggregateId, - aggregateType, - eventType, - eventData, - eventStatus, - createdDate, - ); - } -} diff --git a/src/app/model/publicEvent.model.ts b/src/app/model/publicEvent.model.ts deleted file mode 100644 index 89e43b0..0000000 --- a/src/app/model/publicEvent.model.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AggregateTypeEnum } from "./enums/aggregateType.enum"; -import { EventTypeEnum } from "./enums/eventType.enum"; -import { EventStatusEnum } from "./enums/eventStatus.enum"; -import { EventDataModel } from "./eventDataModel"; - -export class PublicEventModel { - constructor( - public aggregateId: number, - public aggregateType: AggregateTypeEnum, - public eventType: EventTypeEnum, - public eventData: string | EventDataModel, - public eventStatus: EventStatusEnum, - public createdDate: string, - ) {} -} diff --git a/src/app/services/animation/flip.service.spec.ts b/src/app/services/animation/flip.service.spec.ts new file mode 100644 index 0000000..4d9a181 --- /dev/null +++ b/src/app/services/animation/flip.service.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from "@angular/core/testing"; +import { FlipService } from "./flip.service"; +import { AnimationBuilder } from "@angular/animations"; +import { QueryList } from "@angular/core"; + +describe("FlipService", () => { + let service: FlipService; + let queryList: QueryList; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [FlipService, AnimationBuilder], + }); + service = TestBed.inject(FlipService); + queryList = new QueryList(); + }); + + describe("#animate", () => { + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should throw error when components are not set", () => { + expect(() => service.animate(() => {})).toThrowError( + "Components not set", + ); + }); + + it("should not throw error when components are set", () => { + service.setComponents(queryList); + expect(() => service.animate(() => {})).not.toThrowError(); + }); + }); +}); diff --git a/src/app/services/miscellaneous/flip.service.ts b/src/app/services/animation/flip.service.ts similarity index 89% rename from src/app/services/miscellaneous/flip.service.ts rename to src/app/services/animation/flip.service.ts index 83fade3..1ea83b5 100644 --- a/src/app/services/miscellaneous/flip.service.ts +++ b/src/app/services/animation/flip.service.ts @@ -1,10 +1,4 @@ -import { - ChangeDetectorRef, - Inject, - Injectable, - InjectionToken, - QueryList, -} from "@angular/core"; +import { ChangeDetectorRef, Injectable, QueryList } from "@angular/core"; import { animate, AnimationBuilder, @@ -12,31 +6,25 @@ import { style, } from "@angular/animations"; -export const ID_ATTRIBUTE_TOKEN = new InjectionToken("id-attribute"); - /* eslint @typescript-eslint/no-explicit-any: "off" */ -@Injectable() +@Injectable({ + providedIn: "root", +}) export class FlipService { private firstPositions = new Map(); private finalPositions = new Map(); private animatingElementsMap = new Map(); private components: QueryList | null = null; private dataRemovalAttribute = "data-removal-imminent"; + public readonly idAttribute = "data-flip-id"; - constructor( - private animationBuilder: AnimationBuilder, - @Inject(ID_ATTRIBUTE_TOKEN) private idAttribute: string, - ) {} + constructor(private animationBuilder: AnimationBuilder) {} public setComponents(components: QueryList) { this.components = components; } - public animate( - change: () => any, - changeDetectorRef?: ChangeDetectorRef, - removeId?: string, - ) { + public animateRemoval(change: () => any, removeId: string) { console.debug("Components", this.components); if (this.components === null) { throw new Error("Components not set"); @@ -54,6 +42,15 @@ export class FlipService { change = () => player.play(); } + this.animate(change); + } + + public animate(change: () => any, changeDetectorRef?: ChangeDetectorRef) { + console.debug("Components", this.components); + if (this.components === null) { + throw new Error("Components not set"); + } + this.setFirstPositions(this.components); change(); @@ -92,13 +89,13 @@ export class FlipService { components: QueryList, id: string, removalCallback: () => void, - ): AnimationPlayer | null { + ): AnimationPlayer { const element = components.toArray().find((item) => { - return item.getRootElement().getAttribute("data-group-id") === id; + return item.getRootElement().getAttribute("data-flip-id") === id; }); if (!element) { - return null; + throw new Error("Element not found. Cannot animate."); } let animation; diff --git a/src/app/services/miscellaneous/flip.service.spec.ts b/src/app/services/miscellaneous/flip.service.spec.ts deleted file mode 100644 index 851a5ee..0000000 --- a/src/app/services/miscellaneous/flip.service.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { FlipService, ID_ATTRIBUTE_TOKEN } from "./flip.service"; -import { AnimationBuilder } from "@angular/animations"; -import { ChangeDetectorRef, QueryList } from "@angular/core"; - -describe("FlipService", () => { - let service: FlipService; - let queryList: QueryList; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - FlipService, - { provide: ID_ATTRIBUTE_TOKEN, useValue: "test-id" }, - AnimationBuilder, - ], - }); - service = TestBed.inject(FlipService); - queryList = new QueryList(); - }); - - describe("#animate", () => { - it("should be created", () => { - expect(service).toBeTruthy(); - }); - - it("should throw error when components are not set", () => { - expect(() => service.animate(() => {})).toThrowError( - "Components not set", - ); - }); - - it("should not throw error when components are set", () => { - service.setComponents(queryList); - expect(() => service.animate(() => {})).not.toThrowError(); - }); - - it("should animate without changeDetectorRef and removeId", () => { - service.setComponents(queryList); - expect(() => service.animate(() => {})).not.toThrowError(); - }); - - it("should animate with changeDetectorRef and without removeId", () => { - service.setComponents(queryList); - const changeDetectorRef = { - detectChanges: () => {}, - } as ChangeDetectorRef; - expect(() => - service.animate(() => {}, changeDetectorRef), - ).not.toThrowError(); - }); - - it("should animate without changeDetectorRef and with removeId", () => { - service.setComponents(queryList); - expect(() => - service.animate(() => {}, undefined, "removeId"), - ).not.toThrowError(); - }); - - it("should animate with changeDetectorRef and removeId", () => { - service.setComponents(queryList); - const changeDetectorRef = { - detectChanges: () => {}, - } as ChangeDetectorRef; - expect(() => - service.animate(() => {}, changeDetectorRef, "removeId"), - ).not.toThrowError(); - }); - }); -}); diff --git a/src/app/services/miscellaneous/stateTransition.service.spec.ts b/src/app/services/miscellaneous/stateTransition.service.spec.ts deleted file mode 100644 index 78cb3f7..0000000 --- a/src/app/services/miscellaneous/stateTransition.service.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { StatesEnum } from "../../model/enums/states.enum"; -import { StateTransitionService } from "./stateTransition.service"; - -describe("StateTransitionService", () => { - let service: StateTransitionService; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [StateTransitionService], - }); - service = TestBed.inject(StateTransitionService); - jasmine.clock().install(); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); - - it("should transition to a new state", (done: DoneFn) => { - const newState = StatesEnum.NEUTRAL; - const subscription = service.currentState$.subscribe( - (state: StatesEnum) => { - expect(state).toBe(newState); - subscription.unsubscribe(); - done(); - }, - ); - service.transitionTo(newState); - }); - - it("should handle delayed transition", (done: DoneFn) => { - const newState = StatesEnum.NEUTRAL; - const delay = 1000; - - const subscription = service.currentState$.subscribe( - (state: StatesEnum) => { - if (state === newState) { - expect(state).toBe(newState); - subscription.unsubscribe(); - done(); - } - }, - ); - - service.transitionWithQueuedDelayTo(newState, delay); - - jasmine.clock().tick(delay); - }); - - it("should handle multiple delayed transitions", (done: DoneFn) => { - const expectedStates = [ - StatesEnum.LOADING, - StatesEnum.NEUTRAL, - StatesEnum.READY, - ]; - const actualStates: StatesEnum[] = []; - const delay = 1000; - - const subscription = service.currentState$.subscribe( - (state: StatesEnum) => { - actualStates.push(state); - - if (actualStates.length === expectedStates.length) { - expect(actualStates).toEqual(expectedStates); - subscription.unsubscribe(); - done(); - } - }, - ); - - service.transitionTo(StatesEnum.LOADING); - service.transitionWithQueuedDelayTo(StatesEnum.NEUTRAL, delay); - service.transitionWithQueuedDelayTo(StatesEnum.READY, delay); - - expect(actualStates.length).toBe(1); - - jasmine.clock().tick(delay / 2); - expect(actualStates.length).toBe(1); - - jasmine.clock().tick(delay / 2); - expect(actualStates.length).toBe(2); - - jasmine.clock().tick(delay); - expect(actualStates.length).toBe(3); - }); - - afterEach(() => { - jasmine.clock().uninstall(); - }); -}); diff --git a/src/app/services/miscellaneous/stateTransition.service.ts b/src/app/services/miscellaneous/stateTransition.service.ts deleted file mode 100644 index 7062075..0000000 --- a/src/app/services/miscellaneous/stateTransition.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Injectable } from "@angular/core"; -import { Subject } from "rxjs"; -import { StatesEnum } from "../../model/enums/states.enum"; - -@Injectable() -export class StateTransitionService { - private _currentState$ = new Subject(); - - private _lastTransitionDate = Date.now(); - private _timeoutIds = new Set(); // eslint-disable-line @typescript-eslint/no-explicit-any - - transitionTo(newState: StatesEnum): void { - this._timeoutIds.clear(); - this._currentState$.next(newState); - } - - transitionWithQueuedDelayTo( - newState: StatesEnum, - delay: number, - id?: any, // eslint-disable-line @typescript-eslint/no-explicit-any - ): void { - if (!this._timeoutIds.has(id)) { - console.debug("Transition with queued delay to", newState, delay); - this.queueTransitionTo(newState, delay); - return; - } else { - this._timeoutIds.delete(id); - this._lastTransitionDate = Date.now() + delay; - } - console.debug( - "Processing Transition to", - newState, - delay, - Date.now() / 1000, - ); - const timeoutId = setTimeout(() => { - this._currentState$.next(newState); - this._timeoutIds.delete(timeoutId); - }, delay); - - this._timeoutIds.add(timeoutId); - } - - private queueTransitionTo(newState: StatesEnum, delay: number): void { - const timeLeftUntilTransitionAvailable = - this._lastTransitionDate - Date.now(); - console.debug( - "Time left until transition available", - timeLeftUntilTransitionAvailable, - ); - console.debug(this._lastTransitionDate / 1000); - console.debug( - timeLeftUntilTransitionAvailable < 0 - ? 0 - : timeLeftUntilTransitionAvailable, - ); - this._lastTransitionDate = this._lastTransitionDate + delay; - const timeoutId = setTimeout( - () => { - this.transitionWithQueuedDelayTo(newState, delay, timeoutId); - }, - timeLeftUntilTransitionAvailable < 0 - ? 0 - : timeLeftUntilTransitionAvailable, - ); - - this._timeoutIds.add(timeoutId); - } - - get currentState$() { - return this._currentState$.asObservable(); - } -} diff --git a/src/app/services/network/rsocket/ConnectorStatesEnum.ts b/src/app/services/network/rsocket/ConnectorStatesEnum.ts new file mode 100644 index 0000000..a795f54 --- /dev/null +++ b/src/app/services/network/rsocket/ConnectorStatesEnum.ts @@ -0,0 +1,6 @@ +export enum ConnectorStatesEnum { + INITIALIZING = "INITIALIZING", + CONNECTED = "CONNECTED", + RETRYING = "RETRYING", + RETRIES_EXHAUSTED = "RETRIES_EXHAUSTED", +} diff --git a/src/app/services/network/rsocket/codecs/JsonCodec.ts b/src/app/services/network/rsocket/codecs/JsonCodec.ts new file mode 100644 index 0000000..bc2a6d4 --- /dev/null +++ b/src/app/services/network/rsocket/codecs/JsonCodec.ts @@ -0,0 +1,14 @@ +import { Buffer } from "buffer"; +import { Codec } from "rsocket-messaging"; + +export class JsonCodec implements Codec { + mimeType: string = "application/json"; + + decode(buffer: Buffer): T { + return JSON.parse(buffer.toString()); + } + + encode(entity: T): Buffer { + return Buffer.from(JSON.stringify(entity)); + } +} diff --git a/src/app/services/network/rsocket/mediators/abstractRsocketRequest.mediator.spec.ts b/src/app/services/network/rsocket/mediators/abstractRsocketRequest.mediator.spec.ts new file mode 100644 index 0000000..53c8216 --- /dev/null +++ b/src/app/services/network/rsocket/mediators/abstractRsocketRequest.mediator.spec.ts @@ -0,0 +1,86 @@ +import { AbstractRsocketRequestMediator } from "./abstractRsocketRequest.mediator"; +import { ConfigService } from "../../../../config/config.service"; +import { TestBed } from "@angular/core/testing"; +import { RsocketService } from "../rsocket.service"; +import { RetryService } from "../../../retry/retry.service"; +import { UserService } from "../../../user/user.service"; +import { RsocketRequestFactory } from "../rsocketRequest.factory"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { StateEnum } from "../../../state/StateEnum"; + +class MockRsocketRequestMediator extends AbstractRsocketRequestMediator< + any, + any +> { + public sendRequest(): void { + throw new Error("Method not implemented."); + } +} + +describe("AbstractRsocketRequestMediator", () => { + let service: MockRsocketRequestMediator; + let testScheduler: TestScheduler; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: {}, + }, + ], + }); + + service = new MockRsocketRequestMediator( + TestBed.inject(RsocketService), + TestBed.inject(RsocketRequestFactory), + TestBed.inject(RetryService), + TestBed.inject(UserService), + TestBed.inject(ConfigService), + "route", + null, + ); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + describe("#completeEvents", () => { + it("completes the events and request states observables", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const eventEmitter$ = cold("- a b c", { + a: "1", + b: "2", + c: "3", + }); + + eventEmitter$.subscribe((event) => service.nextEvent(event)); + + const statusEmitter$ = cold("- a b c", { + a: StateEnum.REQUESTING, + b: StateEnum.READY, + c: StateEnum.RETRYING, + }); + + statusEmitter$.subscribe((status) => service.nextRequestState(status)); + + const completionEmitter$ = cold("- - a", { a: "1" }); + completionEmitter$.subscribe(() => service.completeEvents()); + + expectObservable(service.getEvents$()).toBe("- a (b|)", { + a: "1", + b: "2", + }); + + expectObservable(service.getState$()).toBe("a b (c|)", { + a: StateEnum.DORMANT, + b: StateEnum.REQUESTING, + c: StateEnum.READY, + }); + }); + }); + }); +}); diff --git a/src/app/services/network/rsocket/mediators/abstractRsocketRequest.mediator.ts b/src/app/services/network/rsocket/mediators/abstractRsocketRequest.mediator.ts new file mode 100644 index 0000000..85fc17c --- /dev/null +++ b/src/app/services/network/rsocket/mediators/abstractRsocketRequest.mediator.ts @@ -0,0 +1,132 @@ +import { + BehaviorSubject, + defer, + Observable, + Subject, + Subscription, +} from "rxjs"; +import { Retryable } from "../../../retry/retryable"; +import { StateEnum } from "../../../state/StateEnum"; +import { RetryService } from "../../../retry/retry.service"; +import { v4 as uuidv4 } from "uuid"; +import { ConfigService } from "../../../../config/config.service"; +import { RequestServiceStateInterface } from "./interfaces/requestServiceState.interface"; +import { RequestServiceComponentInterface } from "./interfaces/requestServiceComponent.interface"; +import { DormantState } from "../../../state/request/dormant.state"; +import { UserService } from "../../../user/user.service"; +import { RsocketService } from "../rsocket.service"; +import { ConnectorStatesEnum } from "../ConnectorStatesEnum"; +import { RequestState } from "../../../state/request/request.state"; +import { RsocketRequestFactory } from "../rsocketRequest.factory"; + +export abstract class AbstractRsocketRequestMediator + implements + Retryable, + RequestServiceStateInterface, + RequestServiceComponentInterface +{ + public accessor state: RequestState; + private _events$: Subject = new Subject(); + private _requestState$: BehaviorSubject = + new BehaviorSubject(StateEnum.DORMANT); + + protected requestObservableSubscription: Subscription | null = null; + protected readonly requestObservableKey: string = uuidv4(); + + public constructor( + public readonly rsocketService: RsocketService, + protected readonly rsocketRequestFactory: RsocketRequestFactory, + protected readonly retryService: RetryService, + protected readonly userService: UserService, + protected readonly configService: ConfigService, + protected readonly route: string, + protected readonly data: TData | null, + ) { + this.state = new DormantState(this); + } + + public get connectorState(): Observable { + return this.rsocketService.connectionState$; + } + + private start() { + if (this.state instanceof DormantState) { + this.onRequest(); + } + } + + public onRequest(): Observable { + return this.state.onRequest(); + } + + public cleanUp(): void { + if ( + this.requestObservableSubscription && + !this.requestObservableSubscription.closed + ) { + this.requestObservableSubscription.unsubscribe(); + } + } + + public abstract sendRequest(): void; + + public getEvents$(start?: boolean): Observable { + return defer(() => { + if (start) this.start(); + return this._events$; + }); + } + + public nextEvent(event: RData): void { + this._events$.next(event); + } + + /** + * Resets event and request status streams. + * Any current subscriber will complete. + */ + public completeEvents(): void { + this._events$.complete(); + this._events$ = new Subject(); + + this._requestState$.complete(); + this._requestState$ = new BehaviorSubject(StateEnum.DORMANT); + } + + public getState$(start?: boolean): Observable { + return defer(() => { + if (start) this.start(); + return this._requestState$; + }); + } + + public get currentState(): StateEnum { + return this._requestState$.getValue(); + } + + public nextRequestState(stateEnum: StateEnum): void { + this._requestState$.next(stateEnum); + } + + public retryNow() { + this.onRequest(); + } + + public get nextRetryTime$(): Observable | undefined { + const rsocketRetryTime$ = this.rsocketService.nextRetryTime$; + + if (rsocketRetryTime$) { + return rsocketRetryTime$; + } + + const nextMediatorRetryTime$ = this.retryService.getNextRetryTime$( + this.requestObservableKey, + ); + + if (nextMediatorRetryTime$) { + return nextMediatorRetryTime$; + } + console.warn("No retry time found"); + return; + } +} diff --git a/src/app/services/network/rsocket/mediators/interfaces/requestServiceComponent.interface.ts b/src/app/services/network/rsocket/mediators/interfaces/requestServiceComponent.interface.ts new file mode 100644 index 0000000..9947efd --- /dev/null +++ b/src/app/services/network/rsocket/mediators/interfaces/requestServiceComponent.interface.ts @@ -0,0 +1,9 @@ +import { Observable } from "rxjs"; +import { StateEnum } from "../../../../state/StateEnum"; +import { Retryable } from "../../../../retry/retryable"; + +export interface RequestServiceComponentInterface extends Retryable { + onRequest(): Observable; + getEvents$(start?: boolean): Observable; + getState$(start?: boolean): Observable; +} diff --git a/src/app/services/network/rsocket/mediators/interfaces/requestServiceState.interface.ts b/src/app/services/network/rsocket/mediators/interfaces/requestServiceState.interface.ts new file mode 100644 index 0000000..0384142 --- /dev/null +++ b/src/app/services/network/rsocket/mediators/interfaces/requestServiceState.interface.ts @@ -0,0 +1,15 @@ +import { StateEnum } from "../../../../state/StateEnum"; +import { RequestState } from "../../../../state/request/request.state"; +import { Observable } from "rxjs"; +import { ConnectorStatesEnum } from "../../ConnectorStatesEnum"; + +export interface RequestServiceStateInterface { + get connectorState(): Observable; + set state(state: RequestState); + sendRequest(): void; + cleanUp(): void; + getEvents$(start?: boolean): Observable; + nextEvent(value: T): void; + completeEvents(): void; + nextRequestState(value: StateEnum): void; +} diff --git a/src/app/services/network/rsocket/mediators/rsocketRequestMediator.factory.spec.ts b/src/app/services/network/rsocket/mediators/rsocketRequestMediator.factory.spec.ts new file mode 100644 index 0000000..06a7a6d --- /dev/null +++ b/src/app/services/network/rsocket/mediators/rsocketRequestMediator.factory.spec.ts @@ -0,0 +1,60 @@ +import { ConfigService } from "../../../../config/config.service"; +import { RsocketRequestMediatorFactory } from "./rsocketRequestMediator.factory"; +import { RsocketService } from "../rsocket.service"; +import { RsocketRequestFactory } from "../rsocketRequest.factory"; +import { RetryService } from "../../../retry/retry.service"; +import { UserService } from "../../../user/user.service"; +import { RsocketRequestResponseMediator } from "./rsocketRequestResponse.mediator"; +import { RsocketRequestStreamMediator } from "./rsocketRequestStream.mediator"; +import { TestBed } from "@angular/core/testing"; + +describe("RsocketRequestMediatorFactory", () => { + let factory: RsocketRequestMediatorFactory; + let rsocketService: RsocketService; // Assume these are mock instances + let rsocketRequestFactory: RsocketRequestFactory; + let retryService: RetryService; + let userService: UserService; + let configService: ConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: {}, + }, + ], + }); + + factory = TestBed.inject(RsocketRequestMediatorFactory); + rsocketService = TestBed.inject(RsocketService); + rsocketRequestFactory = TestBed.inject(RsocketRequestFactory); + retryService = TestBed.inject(RetryService); + userService = TestBed.inject(UserService); + configService = TestBed.inject(ConfigService); + + factory = new RsocketRequestMediatorFactory( + rsocketService, + rsocketRequestFactory, + retryService, + userService, + configService, + ); + }); + + it("should create a RequestResponseMediator correctly", () => { + const mediator = factory.createRequestResponseMediator( + "testRoute", + "testData", + ); + expect(mediator).toBeInstanceOf(RsocketRequestResponseMediator); + }); + + it("should create a StreamMediator correctly", () => { + const mediator = factory.createStreamMediator( + "testRoute", + "testData", + ); + expect(mediator).toBeInstanceOf(RsocketRequestStreamMediator); + }); +}); diff --git a/src/app/services/network/rsocket/mediators/rsocketRequestMediator.factory.ts b/src/app/services/network/rsocket/mediators/rsocketRequestMediator.factory.ts new file mode 100644 index 0000000..0094fb3 --- /dev/null +++ b/src/app/services/network/rsocket/mediators/rsocketRequestMediator.factory.ts @@ -0,0 +1,52 @@ +import { Injectable } from "@angular/core"; +import { RetryService } from "../../../retry/retry.service"; +import { RsocketRequestStreamMediator } from "./rsocketRequestStream.mediator"; +import { ConfigService } from "../../../../config/config.service"; +import { UserService } from "../../../user/user.service"; +import { RsocketService } from "../rsocket.service"; +import { RsocketRequestResponseMediator } from "./rsocketRequestResponse.mediator"; +import { RequestServiceComponentInterface } from "./interfaces/requestServiceComponent.interface"; +import { RsocketRequestFactory } from "../rsocketRequest.factory"; + +@Injectable({ + providedIn: "root", +}) +export class RsocketRequestMediatorFactory { + constructor( + private readonly rsocketService: RsocketService, + private readonly rsocketRequestFactory: RsocketRequestFactory, + private readonly retryService: RetryService, + private readonly userService: UserService, + private readonly configService: ConfigService, + ) {} + + createRequestResponseMediator( + route: string, + data?: TData, + ): RequestServiceComponentInterface { + return new RsocketRequestResponseMediator( + this.rsocketService, + this.rsocketRequestFactory, + this.retryService, + this.userService, + this.configService, + route, + data ?? null, + ); + } + + createStreamMediator( + route: string, + data?: TData, + ): RequestServiceComponentInterface { + return new RsocketRequestStreamMediator( + this.rsocketService, + this.rsocketRequestFactory, + this.retryService, + this.userService, + this.configService, + route, + data ?? null, + ); + } +} diff --git a/src/app/services/network/rsocket/mediators/rsocketRequestResponse.mediator.spec.ts b/src/app/services/network/rsocket/mediators/rsocketRequestResponse.mediator.spec.ts new file mode 100644 index 0000000..197d0b8 --- /dev/null +++ b/src/app/services/network/rsocket/mediators/rsocketRequestResponse.mediator.spec.ts @@ -0,0 +1,128 @@ +import { RsocketService } from "../rsocket.service"; +import { TestBed } from "@angular/core/testing"; +import { RsocketRequestMediatorFactory } from "./rsocketRequestMediator.factory"; +import { RequestServiceComponentInterface } from "./interfaces/requestServiceComponent.interface"; +import { BehaviorSubject } from "rxjs"; +import { ConnectorStatesEnum } from "../ConnectorStatesEnum"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { StateEnum } from "../../../state/StateEnum"; +import { ConfigService } from "../../../../config/config.service"; +import { RsocketRequestFactory } from "../rsocketRequest.factory"; + +describe("RsocketRequestResponseMediator", () => { + let mediator: RequestServiceComponentInterface; + let connectionState$: BehaviorSubject; + let rsocketService: RsocketService; + let rsocketRequestFactory: RsocketRequestFactory; + let testScheduler: TestScheduler; + + beforeEach(() => { + connectionState$ = new BehaviorSubject( + ConnectorStatesEnum.INITIALIZING, + ); + + TestBed.configureTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: {}, + }, + ], + }); + + rsocketService = TestBed.inject(RsocketService); + spyOnProperty(rsocketService, "connectionState$", "get").and.returnValue( + connectionState$.asObservable(), + ); + + mediator = TestBed.inject( + RsocketRequestMediatorFactory, + ).createRequestResponseMediator("route"); + + rsocketRequestFactory = TestBed.inject(RsocketRequestFactory); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + it("sends the request and emits the response when the #start method is called and the connection is ready", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + connectionState$.next(ConnectorStatesEnum.CONNECTED); + + const response = cold("a 5ms |", { a: "response" }); + spyOn(rsocketRequestFactory, "createRequestResponse").and.returnValue( + response, + ); + + expectObservable(mediator.getState$()).toBe("(a b c d) (e |)", { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_ACCEPTED, + e: StateEnum.REQUEST_COMPLETED, + }); + + expectObservable(mediator.getEvents$(true)).toBe("a 5ms |", { + a: "response", + }); + }); + }); + + it("does not send the request when the #start method is called and the connection is not ready", () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + connectionState$.next(ConnectorStatesEnum.INITIALIZING); + + expectObservable(mediator.getState$()).toBe("(a b)", { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + }); + + expectObservable(mediator.getEvents$(true)).toBe("-"); + }); + }); + + it("emits an error when the request is sent", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + connectionState$.next(ConnectorStatesEnum.CONNECTED); + + const response = cold("#", { error: "Simulated Error" }); + spyOn(rsocketRequestFactory, "createRequestResponse").and.returnValue( + response, + ); + + expectObservable(mediator.getState$()).toBe("(a b c d)", { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_REJECTED, + }); + + expectObservable(mediator.getEvents$(true)).toBe("-"); + }); + }); + + it("emits a TimeoutError if the request timeout is reached before a response is received", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + connectionState$.next(ConnectorStatesEnum.CONNECTED); + + const response = cold("-"); + spyOn(rsocketRequestFactory, "createRequestResponse").and.returnValue( + response, + ); + + expectObservable(mediator.getState$()).toBe("(a b c) 4995ms d", { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_TIMEOUT, + }); + + expectObservable(mediator.getEvents$(true)).toBe("-"); + }); + }); +}); diff --git a/src/app/services/network/rsocket/mediators/rsocketRequestResponse.mediator.ts b/src/app/services/network/rsocket/mediators/rsocketRequestResponse.mediator.ts new file mode 100644 index 0000000..e69bc22 --- /dev/null +++ b/src/app/services/network/rsocket/mediators/rsocketRequestResponse.mediator.ts @@ -0,0 +1,39 @@ +import { AbstractRsocketRequestMediator } from "./abstractRsocketRequest.mediator"; +import { StateEnum } from "../../../state/StateEnum"; +import { timeout, TimeoutError } from "rxjs"; + +export class RsocketRequestResponseMediator< + TData, + RData, +> extends AbstractRsocketRequestMediator { + sendRequest(): void { + this.cleanUp(); + + this.requestObservableSubscription = this.rsocketRequestFactory + .createRequestResponse( + this.rsocketService.rsocketRequester!, + this.route, + this.data, + ) + .pipe(timeout(5000)) + .subscribe({ + next: (data: RData) => { + this.nextEvent(data); + this.nextRequestState(StateEnum.REQUEST_ACCEPTED); + }, + complete: () => { + this.nextRequestState(StateEnum.REQUEST_COMPLETED); + this.completeEvents(); + }, + error: (error: Error) => { + if (error instanceof TimeoutError) { + console.error("Timeout error in RsocketRequestMediator: ", error); + this.nextRequestState(StateEnum.REQUEST_TIMEOUT); + return; + } else { + this.nextRequestState(StateEnum.REQUEST_REJECTED); + } + }, + }); + } +} diff --git a/src/app/services/network/rsocket/mediators/rsocketRequestStream.mediator.spec.ts b/src/app/services/network/rsocket/mediators/rsocketRequestStream.mediator.spec.ts new file mode 100644 index 0000000..e420ff6 --- /dev/null +++ b/src/app/services/network/rsocket/mediators/rsocketRequestStream.mediator.spec.ts @@ -0,0 +1,119 @@ +import { RsocketService } from "../rsocket.service"; +import { TestBed } from "@angular/core/testing"; +import { RsocketRequestMediatorFactory } from "./rsocketRequestMediator.factory"; +import { RequestServiceComponentInterface } from "./interfaces/requestServiceComponent.interface"; +import { BehaviorSubject } from "rxjs"; +import { ConnectorStatesEnum } from "../ConnectorStatesEnum"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { StateEnum } from "../../../state/StateEnum"; +import { ConfigService } from "../../../../config/config.service"; +import { RetryOptions } from "../../../retry/retry.options"; +import { RsocketRequestFactory } from "../rsocketRequest.factory"; + +describe("RsocketRequestStreamMediator", () => { + let mediator: RequestServiceComponentInterface; + let connectionState$: BehaviorSubject; + let rsocketService: RsocketService; + let rsocketRequestFactory: RsocketRequestFactory; + let testScheduler: TestScheduler; + + beforeEach(() => { + connectionState$ = new BehaviorSubject( + ConnectorStatesEnum.INITIALIZING, + ); + + const retryOptions: RetryOptions = { + MAX_ATTEMPTS: 2, + MIN_RETRY_INTERVAL: 5, + MAX_RETRY_INTERVAL: 5, + }; + + TestBed.configureTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: { + get retryDefaultStrategy(): RetryOptions { + return retryOptions; + }, + }, + }, + ], + }); + + rsocketService = TestBed.inject(RsocketService); + spyOnProperty(rsocketService, "connectionState$", "get").and.returnValue( + connectionState$.asObservable(), + ); + + mediator = TestBed.inject( + RsocketRequestMediatorFactory, + ).createStreamMediator("route"); + + rsocketRequestFactory = TestBed.inject(RsocketRequestFactory); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + it("sends the request and emits the response when the #start method is called and the connection is ready", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + connectionState$.next(ConnectorStatesEnum.CONNECTED); + + const streamResponse = cold("a 10s - b --- |", { + a: "response1", + b: "response2", + }); + spyOn(rsocketRequestFactory, "createRequestStream").and.returnValue( + streamResponse, + ); + + expectObservable(mediator.getState$()).toBe("(a b c d) 10s (e|)", { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.READY, + e: StateEnum.REQUEST_COMPLETED, + }); + + expectObservable(mediator.getEvents$(true)).toBe("a 10s - b --- |", { + a: "response1", + b: "response2", + }); + }); + }); + + it("emits an error when the request is sent", () => { + testScheduler.run((helpers) => { + const { hot, expectObservable } = helpers; + connectionState$.next(ConnectorStatesEnum.CONNECTED); + + const streamResponse = hot("a 5s #", { + a: "response", + error: "Simulated Error", + }); + spyOn(rsocketRequestFactory, "createRequestStream").and.returnValue( + streamResponse, + ); + + expectObservable(mediator.getState$()).toBe( + "(a b c d) 4995ms e 4999ms (fg)", + { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.READY, + e: StateEnum.RETRYING, + f: StateEnum.RETRYING, + g: StateEnum.REQUEST_REJECTED, + }, + ); + + expectObservable(mediator.getEvents$(true)).toBe("a", { + a: "response", + }); + }); + }); +}); diff --git a/src/app/services/network/rsocket/mediators/rsocketRequestStream.mediator.ts b/src/app/services/network/rsocket/mediators/rsocketRequestStream.mediator.ts new file mode 100644 index 0000000..4e5ea2b --- /dev/null +++ b/src/app/services/network/rsocket/mediators/rsocketRequestStream.mediator.ts @@ -0,0 +1,65 @@ +import { AbstractRsocketRequestMediator } from "./abstractRsocketRequest.mediator"; +import { RequestCompleteState } from "../../../state/request/requestComplete.state"; +import { ReceivingDataState } from "../../../state/request/receivingData.state"; +import { StateEnum } from "../../../state/StateEnum"; +import { RetryDefaultStrategy } from "../../../retry/strategies/retryDefault.strategy"; +import { catchError, throwError } from "rxjs"; + +export class RsocketRequestStreamMediator< + TData, + RData, +> extends AbstractRsocketRequestMediator { + sendRequest(): void { + this.cleanUp(); + + const requestObservable = this.rsocketRequestFactory + .createRequestStream< + TData, + RData + >(this.rsocketService.rsocketRequester!, this.route, this.data) + .pipe( + catchError((error) => { + console.error("Error in RsocketRequestStreamMediator: ", error); + this.nextRequestState(StateEnum.RETRYING); + return throwError(() => error); + }), + ); + + const requestObservableWithRetry = this.retryService.addRetryLogic( + requestObservable, + this.requestObservableKey, + new RetryDefaultStrategy(this.configService), + ); + + this.requestObservableSubscription = requestObservableWithRetry.subscribe({ + next: (data: RData) => { + if (!(this.state instanceof ReceivingDataState)) { + console.log("RsocketRequestResponseMediator: next"); + console.log("Transitioning to ReceivingDataState."); + this.state.cleanUp(); + this.state = new ReceivingDataState(this); + } + console.log("Received data: ", data); + this.nextEvent(data); + }, + error: (error: Error) => { + console.error("Error in RsocketRequestResponseMediator: ", error); + console.log("Transitioning to RequestCompleteState."); + this.nextRequestState(StateEnum.REQUEST_REJECTED); + this.state.cleanUp(); + this.state = new RequestCompleteState(this); + }, + complete: () => { + console.log("RsocketRequestResponseMediator: complete"); + console.log("Transitioning to RequestCompleteState."); + if (this.currentState !== StateEnum.REQUEST_REJECTED) { + this.state.cleanUp(); + this.nextRequestState(StateEnum.REQUEST_COMPLETED); + this.state = new RequestCompleteState(this); + } + + this.completeEvents(); + }, + }); + } +} diff --git a/src/app/services/network/rsocket/requests/rsocketRequests.service.ts b/src/app/services/network/rsocket/requests/rsocketRequests.service.ts deleted file mode 100644 index 1828511..0000000 --- a/src/app/services/network/rsocket/requests/rsocketRequests.service.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Injectable } from "@angular/core"; -import { RsocketService } from "../rsocket.service"; -import { RsocketMetadataService } from "../rsocketMetadata.service"; -import { GroupJoinRequestEvent } from "../../../../model/requestevent/GroupJoinRequestEvent"; -import { v4 as uuidv4 } from "uuid"; -import { GroupLeaveRequestEvent } from "../../../../model/requestevent/GroupLeaveRequestEvent"; -import { Buffer } from "buffer"; -import { MemberModel } from "../../../../model/member.model"; -import { Payload } from "rsocket-core"; - -@Injectable({ - providedIn: "root", -}) -export class RsocketRequestsService { - constructor( - private readonly rsocketService: RsocketService, - private readonly rsocketMetadataService: RsocketMetadataService, - ) {} - - public currentMemberForUser( - callback: (member: MemberModel) => void, - username: string, - password = "empty", - ) { - this.throwIfRsocketIsNotReady(); - - const MEMBER_REQUEST_ROUTE = "groups.user.member"; - const metadata = this.rsocketMetadataService.authMetadataWithRoute( - MEMBER_REQUEST_ROUTE, - username, - password, - ); - - this.rsocketService.rsocketConnection!.requestResponse( - { - data: null, - metadata, - }, - { - onError(error: Error) { - console.error("Error sending member request", error); - }, - onNext: (payload: Payload) => { - const member = this.convertPayloadToMember(payload.data); - console.debug("Current member is", member); - member && callback(member); - }, - onExtension() {}, - onComplete() { - console.debug("Member request completed"); - }, - }, - ); - } - - public sendJoinRequest( - memberName: string, - groupId: number, - username: string, - password = "empty", - ): undefined { - this.throwIfRsocketIsNotReady(); - - const JOIN_GROUP_ROUTE = "groups.join"; - const metadata = this.rsocketMetadataService.authMetadataWithRoute( - JOIN_GROUP_ROUTE, - username, - password, - ); - const groupJoinRequest = new GroupJoinRequestEvent( - uuidv4(), - groupId, - username, - new Date().toISOString(), - memberName, - ); - - this.rsocketService.rsocketConnection!.fireAndForget( - { - data: Buffer.from(JSON.stringify(groupJoinRequest)), - metadata, - }, - { - onError(error: Error) { - console.error("Error sending join request", error); - }, - onComplete() { - console.debug("Join request sent"); - }, - }, - ); - } - - public sendLeaveRequest( - groupId: number, - memberId: number, - username: string, - password = "empty", - ): undefined { - this.throwIfRsocketIsNotReady(); - - const LEAVE_GROUP_ROUTE = "groups.leave"; - const metadata = this.rsocketMetadataService.authMetadataWithRoute( - LEAVE_GROUP_ROUTE, - username, - password, - ); - const groupLeaveRequest = new GroupLeaveRequestEvent( - uuidv4(), - groupId, - username, - new Date().toISOString(), - memberId, - ); - - this.rsocketService.rsocketConnection!.fireAndForget( - { - data: Buffer.from(JSON.stringify(groupLeaveRequest)), - metadata, - }, - { - onError(error: Error) { - console.error("Error sending leave request", error); - }, - onComplete() { - console.debug("Leave request sent"); - }, - }, - ); - } - - private throwIfRsocketIsNotReady() { - if (!this.rsocketService.isConnectionReady) { - throw new Error("RSocket is not initialized"); - } - } - - private convertPayloadToMember( - data: Buffer | null | undefined, - ): MemberModel | null { - if (!data) { - return null; - } - - return JSON.parse(data.toString()) as MemberModel; - } -} diff --git a/src/app/services/network/rsocket/rsocket.service.spec.ts b/src/app/services/network/rsocket/rsocket.service.spec.ts index 04d26f0..89225a1 100644 --- a/src/app/services/network/rsocket/rsocket.service.spec.ts +++ b/src/app/services/network/rsocket/rsocket.service.spec.ts @@ -1,89 +1,169 @@ -import { TestBed } from "@angular/core/testing"; import { RsocketService } from "./rsocket.service"; import { RsocketConnectorService } from "./rsocketConnector.service"; -import { AbstractRetryService } from "../../retry/abstractRetry.service"; -import { cold, getTestScheduler } from "jasmine-marbles"; +import { TestBed } from "@angular/core/testing"; import { ConfigService } from "../../../config/config.service"; -import { RETRY_FOREVER } from "../../../app-tokens"; -import { RsocketPublicUpdateStreamService } from "./streams/rsocketPublicUpdateStream.service"; +import { RSocket } from "rsocket-core"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { take } from "rxjs"; +import { ConnectorStatesEnum } from "./ConnectorStatesEnum"; +import { RetryOptions } from "../../retry/retry.options"; +import { RsocketRequestFactory } from "./rsocketRequest.factory"; describe("RsocketService", () => { let service: RsocketService; - let retryServiceStub: jasmine.SpyObj; - let rsocketConnectorStub: jasmine.SpyObj; - let testModuleConfiguration: any; + let rsocketConnectorService: jasmine.SpyObj; + let rsocketRequestFactory: RsocketRequestFactory; + let mockRSocket: jasmine.SpyObj; - beforeEach(() => { - retryServiceStub = jasmine.createSpyObj( - "AbstractRetryService", - ["addRetryLogic"], - ); - retryServiceStub.addRetryLogic.and.callFake((observable) => observable); + let testScheduler: TestScheduler; - rsocketConnectorStub = jasmine.createSpyObj( + beforeEach(() => { + rsocketConnectorService = jasmine.createSpyObj( "RsocketConnectorService", - ["connectToServer"], + ["connect"], ); - rsocketConnectorStub.connectToServer.and.returnValue(cold("-")); + mockRSocket = jasmine.createSpyObj("RSocket", [ + "requestResponse", + "close", + "onClose", + ]); - testModuleConfiguration = { + const retryOptions: RetryOptions = { + MAX_ATTEMPTS: 2, + MIN_RETRY_INTERVAL: 5, + MAX_RETRY_INTERVAL: 5, + }; + + TestBed.configureTestingModule({ providers: [ RsocketService, - { provide: RsocketConnectorService, useValue: rsocketConnectorStub }, + { provide: RsocketConnectorService, useValue: rsocketConnectorService }, { provide: ConfigService, useValue: { - rsocketMinimumDisconnectRetryTime: 5, - rsocketMaximumDisconnectRetryTime: 5, + get retryForeverStrategy(): RetryOptions { + return retryOptions; + }, }, }, - { - provide: RETRY_FOREVER, - useValue: retryServiceStub, - }, - { - provide: RsocketPublicUpdateStreamService, - useValue: {}, - }, ], - }; - TestBed.configureTestingModule(testModuleConfiguration); + }); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + service = TestBed.inject(RsocketService); + rsocketRequestFactory = TestBed.inject(RsocketRequestFactory); }); - it("should create", () => { - expect(service).toBeTruthy(); + it("should automatically set up the RSocket connection when the service is loaded in", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + const mockPingResponse = cold("--b|", { b: true }); + + rsocketConnectorService.connect.and.returnValue( + cold("a|", { a: mockRSocket }), + ); + spyOn(rsocketRequestFactory, "createRequestResponse").and.returnValue( + mockPingResponse, + ); + + service.initializeRsocketConnection(); + + expectObservable(service.connectionState$).toBe("a--b", { + a: ConnectorStatesEnum.INITIALIZING, + b: ConnectorStatesEnum.CONNECTED, + }); + }); }); - it("should have attempted to connect to the server once", () => { - service.initializeRsocketConnection("test", "test"); - getTestScheduler().flush(); - expect(rsocketConnectorStub.connectToServer).toHaveBeenCalledTimes(1); + it("should transition to the RETRYING state when connecting to the server fails", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + rsocketConnectorService.connect.and.returnValue(cold("---#")); + + service.initializeRsocketConnection(); + + expectObservable(service.connectionState$).toBe("a--b-- 5s (cd)", { + a: ConnectorStatesEnum.INITIALIZING, + b: ConnectorStatesEnum.RETRYING, + c: ConnectorStatesEnum.RETRYING, + d: ConnectorStatesEnum.RETRIES_EXHAUSTED, + }); + + flush(); + + expect(mockRSocket.onClose).not.toHaveBeenCalled(); + }); }); - it("should attempt to reconnect to the server after connection is closed based on the configured delay", () => { - jasmine.clock().install(); + it("should transition to the RETRYING state when pinging fails after connecting to the server", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + const mockPingResponse = cold("---#"); + + rsocketConnectorService.connect.and.returnValue( + cold("a|", { a: mockRSocket }), + ); + spyOn(rsocketRequestFactory, "createRequestResponse").and.returnValue( + mockPingResponse, + ); + + service.initializeRsocketConnection(); - const rsocketMock = jasmine.createSpyObj("RSocket", ["onClose"]); - let onCloseCallback: any; - rsocketMock.onClose.and.callFake((callback: any) => { - onCloseCallback = callback; + expectObservable(service.connectionState$).toBe("a---b--- 5s (cd)", { + a: ConnectorStatesEnum.INITIALIZING, + b: ConnectorStatesEnum.RETRYING, + c: ConnectorStatesEnum.RETRYING, + d: ConnectorStatesEnum.RETRIES_EXHAUSTED, + }); + + flush(); + + expect(mockRSocket.onClose).not.toHaveBeenCalled(); }); + }); - rsocketConnectorStub.connectToServer.and.returnValue( - cold("a|", { a: rsocketMock }), - ); + it("should automatically retry connection setup after the connection has been closed", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + const mockPingResponse = cold("--b|", { b: true }); + + let onCloseCallBack: any; + mockRSocket.onClose.and.callFake((callback) => { + onCloseCallBack = callback; + }); - service.initializeRsocketConnection("test", "test"); + rsocketConnectorService.connect.and.returnValue( + cold("a|", { a: mockRSocket }), + ); + spyOn(rsocketRequestFactory, "createRequestResponse").and.returnValue( + mockPingResponse, + ); - getTestScheduler().flush(); + service.initializeRsocketConnection(); - expect(rsocketMock.onClose).toHaveBeenCalledTimes(1); - onCloseCallback(); - jasmine.clock().tick(5000); - expect(rsocketConnectorStub.connectToServer).toHaveBeenCalledTimes(2); + expectObservable(service.connectionState$).toBe("a--b", { + a: ConnectorStatesEnum.INITIALIZING, + b: ConnectorStatesEnum.CONNECTED, + }); - jasmine.clock().uninstall(); + flush(); + + expect(mockRSocket.onClose).toHaveBeenCalled(); + + service.connectionState$.pipe(take(1)).subscribe((state) => { + if (state === ConnectorStatesEnum.CONNECTED) { + onCloseCallBack(); + } + }); + + expectObservable(service.connectionState$).toBe("----a--b", { + a: ConnectorStatesEnum.RETRYING, + b: ConnectorStatesEnum.CONNECTED, + }); + }); }); }); diff --git a/src/app/services/network/rsocket/rsocket.service.ts b/src/app/services/network/rsocket/rsocket.service.ts index fccf122..093e62a 100644 --- a/src/app/services/network/rsocket/rsocket.service.ts +++ b/src/app/services/network/rsocket/rsocket.service.ts @@ -1,52 +1,74 @@ -// Inspired from https://github.com/rsocket/rsocket-js/blob/1.0.x-alpha/packages/rsocket-examples/src/rxjs/RxjsMessagingCompositeMetadataRouteExample.ts#L121 -import { Inject, Injectable } from "@angular/core"; -import { BehaviorSubject, catchError, Subscription, tap } from "rxjs"; -import { AbstractRetryService } from "../../retry/abstractRetry.service"; -import { ConfigService } from "../../../config/config.service"; -import { RETRY_FOREVER } from "../../../app-tokens"; +import { Injectable } from "@angular/core"; +import { + BehaviorSubject, + catchError, + concatWith, + defer, + EMPTY, + Observable, + of, + Subscription, + tap, + throwError, +} from "rxjs"; import { RsocketConnectorService } from "./rsocketConnector.service"; import { RSocket } from "rsocket-core"; +import { RetryService } from "../../retry/retry.service"; +import { RSocketRequester } from "rsocket-messaging"; +import { Retryable } from "../../retry/retryable"; +import { v4 as uuidv4 } from "uuid"; +import { RetryForeverStrategy } from "../../retry/strategies/retryForever.strategy"; +import { ConfigService } from "../../../config/config.service"; +import { ConnectorStatesEnum } from "./ConnectorStatesEnum"; +import { RsocketRequestFactory } from "./rsocketRequest.factory"; @Injectable({ providedIn: "root", }) -export class RsocketService { - private readonly MINIMUM_DISCONNECT_RETRY_TIME: number = 5; - private readonly MAXIMUM_DISCONNECT_RETRY_TIME: number = 10; +export class RsocketService implements Retryable { private readonly _rsocketConnection$ = new BehaviorSubject( null, ); - private readonly _isConnectionReady$ = new BehaviorSubject(false); - + private readonly _connectionState$ = new BehaviorSubject( + ConnectorStatesEnum.INITIALIZING, + ); private connectionSubscription: Subscription | null = null; + public readonly requestObservableKey: string = uuidv4(); constructor( private readonly rsocketConnectorService: RsocketConnectorService, - configService: ConfigService, - @Inject(RETRY_FOREVER) private readonly retryService: AbstractRetryService, - ) { - console.debug("Retries:", this.retryService); - - if (configService) { - this.MINIMUM_DISCONNECT_RETRY_TIME = - configService.rsocketMinimumDisconnectRetryTime ?? - this.MINIMUM_DISCONNECT_RETRY_TIME; - this.MAXIMUM_DISCONNECT_RETRY_TIME = - configService.rsocketMaximumDisconnectRetryTime ?? - this.MAXIMUM_DISCONNECT_RETRY_TIME; + private readonly rsocketRequestFactory: RsocketRequestFactory, + private readonly retryService: RetryService, + private readonly configService: ConfigService, + ) {} + + public get nextRetryTime$(): Observable | undefined { + const nextRetryTime$ = this.retryService.getNextRetryTime$( + this.requestObservableKey, + ); + + if (!nextRetryTime$) { + console.warn("No retry time found"); + return; } + + return nextRetryTime$; + } + + retryNow(): void { + throw new Error("Method not implemented."); } - public initializeRsocketConnection(username: string, password = "empty") { - this.setupRsocketService(username, password); + public initializeRsocketConnection() { + this.setupRsocketService(); } - public get isConnectionReady$() { - return this._isConnectionReady$.asObservable(); + public get connectionState$() { + return this._connectionState$.asObservable(); } - public get isConnectionReady(): boolean { - return this._isConnectionReady$.getValue(); + public get connectionState(): ConnectorStatesEnum { + return this._connectionState$.getValue(); } public get rsocketConnection$() { @@ -57,64 +79,87 @@ export class RsocketService { return this._rsocketConnection$.getValue(); } + public get rsocketRequester(): RSocketRequester | null { + const rsocket = this.rsocketConnection; + + if (rsocket) { + return RSocketRequester.wrap(rsocket); + } + + return null; + } + + private onCloseHandler(error: Error | undefined) { + console.debug("Connection closed with error:", error); + this._connectionState$.next(ConnectorStatesEnum.RETRYING); + this._rsocketConnection$.next(null); + this.connectionSubscription?.unsubscribe(); + this.setupRsocketService(); + } + /** - * This method is called in the service's constructor. - * If the RSocket connection is terminated for whatever reason, it is considered - * to be COMPLETE. In this case, the RSocket's onClose handler calls this method to restart the subscription. + * 3 phases: + * 1. Attempts connection to server + * 2. Once connection is established, attempts to ping the server + * 3. If ping is successful, sets connection state to CONNECTED and registers onClose handler + * If connection or ping attempts fail, this method will retry based on the provided retry strategy + * (currently RetryForeverStrategy). If the connection is closed after these steps, the registered + * onClose handler calls #onCloseHandler to attempt to recreate the connection. * @private */ - private setupRsocketService(username: string, password = "empty") { - const connectWithRetry = () => { - return this.retryService - .addRetryLogic( - this.rsocketConnectorService.connectToServer(username, password), - ) - .pipe( - tap((rsocket) => { - console.debug("RSocket object:", rsocket); - this._rsocketConnection$.next(rsocket); - this._isConnectionReady$.next(true); - - // Setup onClose handler with delay - rsocket.onClose((error) => { - console.debug("Connection closed with error:", error); - this._isConnectionReady$.next(false); - this._rsocketConnection$.next(null); - - // Use setTimeout to delay reconnection attempt - setTimeout(() => { - console.debug("Attempting to re-establish connection..."); - this.setupRsocketService(username, password); - }, this.calculateRetryIntervalWithJitter()); - }); - }), - catchError((error) => { - throw new Error( - "This error should never be reached! You should be retrying indefinitely. Error:", - error, - ); - }), - ); - }; + private setupRsocketService() { + const connector = this.rsocketConnectorService.connect(); - // Initiate connection logic - this.connectionSubscription?.unsubscribe(); - this.connectionSubscription = connectWithRetry().subscribe(); - } + console.debug("Setting up RSocket service"); - private getMinimumDisconnectRetryTimeMilliseconds() { - return this.MINIMUM_DISCONNECT_RETRY_TIME * 1000; - } + const rsocketConnectionObservable = connector.pipe( + tap((rsocket) => { + console.debug( + "Connected to server in RSocketConnectorService", + rsocket, + ); + this._rsocketConnection$.next(rsocket); + }), + concatWith( + defer(() => { + return this.rsocketRequestFactory + .createRequestResponse(this.rsocketRequester!, "groups.ping", null) + .pipe( + tap((response) => { + console.debug("Ping response", response); + this.rsocketConnection!.onClose((error) => + this.onCloseHandler(error), + ); + this._connectionState$.next(ConnectorStatesEnum.CONNECTED); + }), + ); + }), + ), + catchError((error) => { + console.error("Error in RSocket Connection", error); + this._connectionState$.next(ConnectorStatesEnum.RETRYING); + return throwError(() => error); // return error to trigger retry behavior + }), + ); - private getMaximumDisconnectRetryTimeMilliseconds() { - return this.MAXIMUM_DISCONNECT_RETRY_TIME * 1000; - } + this.connectionSubscription?.unsubscribe(); - private calculateRetryIntervalWithJitter(): number { - const min = this.getMinimumDisconnectRetryTimeMilliseconds(); - const max = this.getMaximumDisconnectRetryTimeMilliseconds(); - const delay = Math.random() * (max - min) + min; - console.debug("Delay is", delay); - return delay; + this.connectionSubscription = this.retryService + .addRetryLogic( + rsocketConnectionObservable, + this.requestObservableKey, + new RetryForeverStrategy(this.configService), + ) + .pipe( + catchError((error) => { + console.error( + "Retries exhausted, giving up establishing RSocket connection", + error, + ); + this._connectionState$.next(ConnectorStatesEnum.RETRIES_EXHAUSTED); + return of(EMPTY); + }), + ) + .subscribe(); } } diff --git a/src/app/services/network/rsocket/rsocketConnector.service.ts b/src/app/services/network/rsocket/rsocketConnector.service.ts index 4ed90e2..97ed680 100644 --- a/src/app/services/network/rsocket/rsocketConnector.service.ts +++ b/src/app/services/network/rsocket/rsocketConnector.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; -import { catchError, defer, from, tap } from "rxjs"; -import { RSocketConnector } from "rsocket-core"; +import { defer, Observable } from "rxjs"; +import { RSocket, RSocketConnector } from "rsocket-core"; import { WebsocketClientTransport } from "rsocket-websocket-client"; import { ConfigService } from "../../../config/config.service"; import { RsocketMetadataService } from "./rsocketMetadata.service"; @@ -30,34 +30,6 @@ export class RsocketConnectorService { } } - /** - * Connects to the RSocket server. Should be used with RetryService. - * This will only fail if the initial connection fails. - * This will NOT fail if an RSocket connection is established and then lost. - * @private - */ - public connectToServer(username: string, password = "empty") { - const rSocketConnector = this.createConnector(username, password); - - return defer(() => - from(rSocketConnector.connect()).pipe( - tap((rsocket) => { - console.debug( - "Connected to server in RSocketConnectorService", - rsocket, - ); - }), - catchError((error) => { - console.debug( - "Error connecting to server in RSocketConnector Service:", - error, - ); - throw new Error("Failed to connect to server"); - }), - ), - ); - } - private getKeepAliveTimeMilliseconds() { return this.KEEP_ALIVE * 1000; } @@ -66,17 +38,18 @@ export class RsocketConnectorService { return this.LIFETIME * 1000; } - private createConnector(username = "user", password = "empty") { + public connect(): Observable { + return defer(() => this.createConnector().connect()); + } + + private createConnector() { return new RSocketConnector({ setup: { dataMimeType: "application/json", metadataMimeType: "message/x.rsocket.composite-metadata.v0", payload: { data: null, - metadata: this.rsocketMetadataService.authMetadata( - username, - password, - ), + metadata: this.rsocketMetadataService.authMetadataAsBuffer(), }, keepAlive: this.getKeepAliveTimeMilliseconds(), // interval (ms) to send keep-alive frames lifetime: this.getLifetimeTimeMilliseconds(), // time (ms) since last keep-alive acknowledgement that the connection will be considered dead diff --git a/src/app/services/network/rsocket/rsocketMetadata.service.ts b/src/app/services/network/rsocket/rsocketMetadata.service.ts index 3b055f0..bce89bc 100644 --- a/src/app/services/network/rsocket/rsocketMetadata.service.ts +++ b/src/app/services/network/rsocket/rsocketMetadata.service.ts @@ -1,53 +1,51 @@ import { Injectable } from "@angular/core"; import { encodeCompositeMetadata, - encodeRoute, encodeSimpleAuthMetadata, WellKnownMimeType, } from "rsocket-composite-metadata"; import { Buffer } from "buffer"; import { BufferPolyfill } from "./buffer.polyfill"; +import { UserService } from "../../user/user.service"; +import { RequestSpec, RSocketRequester } from "rsocket-messaging"; @Injectable({ providedIn: "root", }) export class RsocketMetadataService { - constructor(readonly bufferPolyfill: BufferPolyfill) {} + constructor( + readonly bufferPolyfill: BufferPolyfill, + readonly userService: UserService, + ) {} - public authMetadata(username: string, password = "empty") { - const encodedSimpleAuthMetadata = encodeSimpleAuthMetadata( - username, - password, - ); + private get encodedSimpleAuthMetadata(): Buffer { + const username = this.userService.uuid; + const password = "empty"; + + return encodeSimpleAuthMetadata(username, password); + } + public authMetadataAsBuffer() { const map = new Map(); map.set( WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION, - encodedSimpleAuthMetadata, + this.encodedSimpleAuthMetadata, ); return encodeCompositeMetadata(map); } - public authMetadataWithRoute( - route: string, - username: string, - password = "empty", - ) { - const encodedSimpleAuthMetadata = encodeSimpleAuthMetadata( - username, - password, - ); - - const encodedRoute: Buffer = encodeRoute(route); - - const map = new Map(); - map.set(WellKnownMimeType.MESSAGE_RSOCKET_ROUTING, encodedRoute); - map.set( + public authMetadata(requestSpec: RequestSpec): RequestSpec { + return requestSpec.metadata( WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION, - encodedSimpleAuthMetadata, + this.encodedSimpleAuthMetadata, ); + } - return encodeCompositeMetadata(map); + public authMetadataWithRoute( + route: string, + rsocketRequester: RSocketRequester, + ): RequestSpec { + return this.authMetadata(rsocketRequester.route(route)); } } diff --git a/src/app/services/network/rsocket/rsocketRequest.factory.spec.ts b/src/app/services/network/rsocket/rsocketRequest.factory.spec.ts new file mode 100644 index 0000000..e4b6aee --- /dev/null +++ b/src/app/services/network/rsocket/rsocketRequest.factory.spec.ts @@ -0,0 +1,66 @@ +import { RsocketRequestFactory } from "./rsocketRequest.factory"; +import { TestBed } from "@angular/core/testing"; +import { ConfigService } from "../../../config/config.service"; +import { RsocketMetadataService } from "./rsocketMetadata.service"; +import { of } from "rxjs"; +import { RsocketService } from "./rsocket.service"; + +describe("RsocketRequestFactory", () => { + let factory: RsocketRequestFactory; + let rsocketService: RsocketService; + let rsocketMetadataService: RsocketMetadataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: ConfigService, useValue: {} }], + }); + + factory = TestBed.inject(RsocketRequestFactory); + rsocketMetadataService = TestBed.inject(RsocketMetadataService); + rsocketService = TestBed.inject(RsocketService); + + spyOnProperty(rsocketService, "rsocketRequester", "get").and.returnValue( + {} as any, + ); + + const observable = of("test"); + + spyOn(rsocketMetadataService, "authMetadataWithRoute").and.returnValue({ + request: () => observable, + } as any); + }); + + describe("createRequestResponse", () => { + it("should return a new observable for every re-subscription", () => { + const observableFactory = factory.createRequestResponse( + {} as any, + "test", + null, + ); + + observableFactory.subscribe(); + observableFactory.subscribe(); + + expect( + rsocketMetadataService.authMetadataWithRoute, + ).toHaveBeenCalledTimes(2); + }); + }); + + describe("createRequestStream", () => { + it("should return a new observable for every re-subscription", () => { + const observableFactory = factory.createRequestStream( + {} as any, + "test", + null, + ); + + observableFactory.subscribe(); + observableFactory.subscribe(); + + expect( + rsocketMetadataService.authMetadataWithRoute, + ).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/app/services/network/rsocket/rsocketRequest.factory.ts b/src/app/services/network/rsocket/rsocketRequest.factory.ts new file mode 100644 index 0000000..4567e74 --- /dev/null +++ b/src/app/services/network/rsocket/rsocketRequest.factory.ts @@ -0,0 +1,55 @@ +import { Injectable } from "@angular/core"; +import { RsocketMetadataService } from "./rsocketMetadata.service"; +import { JsonCodec } from "./codecs/JsonCodec"; +import { RSocketRequester } from "rsocket-messaging"; +import { RxRequestersFactory } from "rsocket-adapter-rxjs"; +import { defer } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class RsocketRequestFactory { + constructor( + private readonly rsocketMetadataService: RsocketMetadataService, + ) {} + + public createRequestResponse( + rsocketRequester: RSocketRequester, + route: string, + data: TData | null, + ) { + return defer(() => + this.rsocketMetadataService + .authMetadataWithRoute(route, rsocketRequester) + .request( + RxRequestersFactory.requestResponse( + data, + this.jsonCodec(), + this.jsonCodec(), + ), + ), + ); + } + + public createRequestStream( + rsocketRequester: RSocketRequester, + route: string, + data: TData | null, + ) { + return defer(() => + this.rsocketMetadataService + .authMetadataWithRoute(route, rsocketRequester) + .request( + RxRequestersFactory.requestStream( + data, + this.jsonCodec(), + this.jsonCodec(), + ), + ), + ); + } + + private jsonCodec() { + return new JsonCodec(); + } +} diff --git a/src/app/services/network/rsocket/streams/rsocketPrivateUpdateStream.service.ts b/src/app/services/network/rsocket/streams/rsocketPrivateUpdateStream.service.ts deleted file mode 100644 index f56d3fc..0000000 --- a/src/app/services/network/rsocket/streams/rsocketPrivateUpdateStream.service.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Injectable } from "@angular/core"; -import { PrivateEventModel } from "../../../../model/privateEvent.model"; -import { BehaviorSubject, Subject } from "rxjs"; -import { - Cancellable, - OnExtensionSubscriber, - Payload, - Requestable, - RSocket, -} from "rsocket-core"; -import { RsocketService } from "../rsocket.service"; -import { RsocketMetadataService } from "../rsocketMetadata.service"; -import { Buffer } from "buffer"; - -@Injectable({ - providedIn: "root", -}) -export class RsocketPrivateUpdateStreamService { - private _privateUpdatesStream$ = new Subject(); - private readonly _isPrivateUpdatesStreamReady$ = new BehaviorSubject( - false, - ); - private _privateUpdatesStream: - | (Requestable & Cancellable & OnExtensionSubscriber) - | null = null; - - constructor( - private readonly rsocketService: RsocketService, - private readonly rsocketMetadataService: RsocketMetadataService, - ) {} - - public initializePrivateUpdateStream(username: string, password = "empty") { - this.rsocketService.rsocketConnection$.subscribe((rsocket) => { - if (rsocket) { - console.debug("RSocket is ready. Creating private update stream"); - this.createPrivateUpdateStream(rsocket, username, password); - this._isPrivateUpdatesStreamReady$.next(true); - } else { - this._isPrivateUpdatesStreamReady$.next(false); - } - }); - } - - public get isPrivateUpdatesStreamReady$() { - return this._isPrivateUpdatesStreamReady$.asObservable(); - } - - public get isPrivateUpdatesStreamReady(): boolean { - return this._isPrivateUpdatesStreamReady$.getValue(); - } - - public get privateUpdatesStream$() { - return this._privateUpdatesStream$.asObservable(); - } - - /** - * Creates the privateUpdatesStream$. - * @private - */ - private createPrivateUpdateStream( - rsocket: RSocket, - username: string, - password = "empty", - ) { - if (!rsocket) { - throw new Error("RSocket is not initialized"); - } - - console.debug("Establishing Private Update Stream"); - const PRIVATE_UPDATES_ROUTES = "groups.updates.user"; - const metadata = this.rsocketMetadataService.authMetadataWithRoute( - PRIVATE_UPDATES_ROUTES, - username, - password, - ); - - this._privateUpdatesStream?.cancel(); - this._privateUpdatesStream = rsocket.requestStream( - { - data: null, - metadata, - }, - 2100000000, - { - onError: (error: Error) => { - this._isPrivateUpdatesStreamReady$.next(false); - console.debug( - `An error has occurred on the ${PRIVATE_UPDATES_ROUTES} request stream`, - error, - ); - this._privateUpdatesStream$?.error(error); - }, - onNext: (payload: Payload, isComplete: boolean) => { - const event = this.generatePrivateEvent(payload.data); - if (event) { - this._privateUpdatesStream$?.next(event); - } - - console.debug("Received payload. Data: ", event); - console.debug("Event data: ", event?.eventData); - console.debug("IsStreamComplete:", isComplete); - }, - onComplete: () => { - this._isPrivateUpdatesStreamReady$.next(false); - console.debug(`${PRIVATE_UPDATES_ROUTES} stream completed`); - this._privateUpdatesStream$?.complete(); - }, - onExtension: () => { - console.debug("This is required but not used"); - }, - }, - ); - } - - private generatePrivateEvent( - data: Buffer | null | undefined, - ): PrivateEventModel | null { - if (!data) { - return null; - } - - return JSON.parse(data.toString()) as PrivateEventModel; - } -} diff --git a/src/app/services/network/rsocket/streams/rsocketPublicUpdateStream.service.ts b/src/app/services/network/rsocket/streams/rsocketPublicUpdateStream.service.ts deleted file mode 100644 index 089e50d..0000000 --- a/src/app/services/network/rsocket/streams/rsocketPublicUpdateStream.service.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Injectable } from "@angular/core"; -import { - Cancellable, - OnExtensionSubscriber, - Payload, - Requestable, - RSocket, -} from "rsocket-core"; -import { Buffer } from "buffer"; -import { PublicEventModel } from "../../../../model/publicEvent.model"; -import { BehaviorSubject, Subject } from "rxjs"; -import { RsocketMetadataService } from "../rsocketMetadata.service"; -import { RsocketService } from "../rsocket.service"; - -@Injectable({ - providedIn: "root", -}) -export class RsocketPublicUpdateStreamService { - private _publicUpdatesStream$ = new Subject(); - private readonly _isPublicUpdatesStreamReady$ = new BehaviorSubject( - false, - ); - private _publicUpdatesStream: - | (Requestable & Cancellable & OnExtensionSubscriber) - | null = null; - - constructor( - private readonly rsocketService: RsocketService, - private readonly rsocketMetadataService: RsocketMetadataService, - ) {} - - public initializePublicUpdateStream(username: string, password = "empty") { - if (this._isPublicUpdatesStreamReady$.getValue()) { - console.debug("Public update stream is already initialized"); - return; - } - - this.rsocketService.rsocketConnection$.subscribe((rsocket) => { - if (rsocket) { - console.debug("RSocket is ready. Creating public update stream"); - this.createPublicUpdateStream(rsocket, username, password); - this._isPublicUpdatesStreamReady$.next(true); - } else { - console.debug("RSocket is not ready"); - this._isPublicUpdatesStreamReady$.next(false); - } - }); - } - - public get isPublicUpdatesStreamReady$() { - return this._isPublicUpdatesStreamReady$.asObservable(); - } - - public get publicUpdatesStream$() { - return this._publicUpdatesStream$.asObservable(); - } - - /** - * Creates the publicUpdatesStream$. - * @private - */ - private createPublicUpdateStream( - rsocket: RSocket, - username: string, - password = "empty", - ) { - if (!rsocket) { - throw new Error("RSocket is not initialized"); - } - - console.debug("Establishing Public Update Stream"); - const PUBLIC_UPDATES_ROUTES = "groups.updates.all"; - const metadata = this.rsocketMetadataService.authMetadataWithRoute( - PUBLIC_UPDATES_ROUTES, - username, - password, - ); - - this._publicUpdatesStream?.cancel(); - this._publicUpdatesStream = rsocket.requestStream( - { - data: null, - metadata: metadata, - }, - 2100000000, - { - onError: (error: Error) => { - this._isPublicUpdatesStreamReady$.next(false); - console.debug( - `An error has occurred on the ${PUBLIC_UPDATES_ROUTES} request stream`, - error, - ); - this._publicUpdatesStream$?.error(error); - }, - onNext: (payload: Payload, isComplete: boolean) => { - const event = this.generatePublicEvent(payload.data); - if (event) { - this._publicUpdatesStream$?.next(event); - } - - console.debug("Received payload. Data: ", event); - console.debug("Event data: ", event?.eventData); - console.debug("IsStreamComplete:", isComplete); - }, - onComplete: () => { - this._isPublicUpdatesStreamReady$.next(false); - console.debug(`${PUBLIC_UPDATES_ROUTES} stream completed`); - this._publicUpdatesStream$?.complete(); - }, - onExtension: () => { - console.debug("This is required but not used"); - }, - }, - ); - } - - private generatePublicEvent( - data: Buffer | null | undefined, - ): PublicEventModel | null { - if (!data) { - return null; - } - - return JSON.parse(data.toString()) as PublicEventModel; - } -} diff --git a/src/app/services/notifications/asyncRequestStatus.service.spec.ts b/src/app/services/notifications/asyncRequestStatus.service.spec.ts new file mode 100644 index 0000000..b6211ac --- /dev/null +++ b/src/app/services/notifications/asyncRequestStatus.service.spec.ts @@ -0,0 +1,271 @@ +import { AsyncRequestStatusService } from "./asyncRequestStatus.service"; +import { TestBed } from "@angular/core/testing"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { Observable } from "rxjs"; +import { Event } from "../../model/events/event"; +import { AggregateTypeEnum } from "../../model/enums/aggregateType.enum"; +import { EventTypeEnum } from "../../model/enums/eventType.enum"; +import { EventStatusEnum } from "../../model/enums/eventStatus.enum"; +import { v4 as uuidv4 } from "uuid"; +import { StateEnum } from "../state/StateEnum"; + +function createMockEvent(): Event { + return { + eventId: uuidv4(), + aggregateId: 1, + aggregateType: AggregateTypeEnum.GROUP, + eventType: EventTypeEnum.GROUP_CREATED, + eventData: {}, + eventStatus: EventStatusEnum.SUCCESSFUL, + createdDate: Date.now().toString(), + + accept: jasmine.createSpy("accept"), + }; +} + +describe("AsyncRequestStatusService", () => { + let service: AsyncRequestStatusService; + let testScheduler: TestScheduler; + const mockEvents: Event[] = []; + + beforeEach(() => { + for (let i = 0; i < 10; i++) { + mockEvents.push(createMockEvent()); + } + + TestBed.configureTestingModule({ + providers: [AsyncRequestStatusService], + }); + + service = TestBed.inject(AsyncRequestStatusService); + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + describe("successful async request", () => { + it("should set the status to EVENT_PROCESSED and return the response when the event stream emits a response event", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + + const eventStream: Observable = cold("a---b---c", { + a: mockEvents[0], + b: mockEvents[1], + c: mockEvents[2], + }); + + const statusStream: Observable = cold("--a-b-c--|", { + a: StateEnum.INITIALIZING, + b: StateEnum.REQUESTING, + c: StateEnum.REQUEST_ACCEPTED, + }); + + const requestId = mockEvents[2].eventId; + + const asyncRequest$ = service.observeRequestCompletion( + eventStream, + statusStream, + requestId, + ); + + expectObservable(asyncRequest$).toBe("--------(a|)", { + a: mockEvents[2], + }); + + expectObservable(service.getRequestStatus$(requestId)).toBe( + "a-b-c-d-(e|)", + { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_ACCEPTED, + e: StateEnum.EVENT_PROCESSED, + }, + ); + + flush(); + + expectObservable(service.getRequestStatus$(requestId)).toBe( + "8ms #", + undefined, + new Error(`Request with id ${requestId} is not being processed`), + ); + }); + }); + + it("should continue observing for the response event even if the status stream does not complete", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const eventStream: Observable = cold("a---b---c", { + a: mockEvents[0], + b: mockEvents[1], + c: mockEvents[2], + }); + + const statusStream: Observable = cold("--a-b-c--", { + a: StateEnum.INITIALIZING, + b: StateEnum.REQUESTING, + c: StateEnum.REQUEST_ACCEPTED, + }); + + const requestId = mockEvents[2].eventId; + + const asyncRequest$ = service.observeRequestCompletion( + eventStream, + statusStream, + requestId, + ); + + expectObservable(asyncRequest$).toBe("--------(a|)", { + a: mockEvents[2], + }); + + expectObservable(service.getRequestStatus$(requestId)).toBe( + "a-b-c-d-(e|)", + { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_ACCEPTED, + e: StateEnum.EVENT_PROCESSED, + }, + ); + }); + }); + }); + + describe("unsuccessful async request", () => { + it("should return an error and set the status if the status stream emits a REQUEST_REJECTED status", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + + const eventStream: Observable = cold("a---b---", { + a: mockEvents[0], + b: mockEvents[1], + }); + + const statusStream: Observable = cold("--a-b-c--", { + a: StateEnum.INITIALIZING, + b: StateEnum.REQUESTING, + c: StateEnum.REQUEST_REJECTED, + }); + + const requestId = mockEvents[2].eventId; + + const asyncRequest$ = service.observeRequestCompletion( + eventStream, + statusStream, + requestId, + ); + + expectObservable(asyncRequest$).toBe( + "------#", + undefined, + new Error(StateEnum.REQUEST_REJECTED), + ); + + expectObservable(service.getRequestStatus$(requestId)).toBe( + "a-b-c-(d|)", + { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_REJECTED, + }, + ); + + flush(); + + expectObservable(service.getRequestStatus$(requestId)).toBe( + "6ms #", + undefined, + new Error(`Request with id ${requestId} is not being processed`), + ); + }); + }); + + it("should return an error and set the status if the status stream emits a REQUEST_TIMEOUT status", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const eventStream: Observable = cold("a---b---", { + a: mockEvents[0], + b: mockEvents[1], + }); + + const statusStream: Observable = cold("--a-b-c--", { + a: StateEnum.INITIALIZING, + b: StateEnum.REQUESTING, + c: StateEnum.REQUEST_TIMEOUT, + }); + + const requestId = mockEvents[2].eventId; + + const asyncRequest$ = service.observeRequestCompletion( + eventStream, + statusStream, + requestId, + ); + + expectObservable(asyncRequest$).toBe( + "------#", + undefined, + new Error(StateEnum.REQUEST_TIMEOUT), + ); + + expectObservable(service.getRequestStatus$(requestId)).toBe( + "a-b-c-(d|)", + { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_TIMEOUT, + }, + ); + }); + }); + + it("should return an error and set the status if the event stream does not emit the response event within the timeout", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const eventStream: Observable = cold("a---b---", { + a: mockEvents[0], + b: mockEvents[1], + }); + + const statusStream: Observable = cold("--a-b-c--", { + a: StateEnum.INITIALIZING, + b: StateEnum.REQUESTING, + c: StateEnum.REQUEST_ACCEPTED, + }); + + const requestId = mockEvents[2].eventId; + + const asyncRequest$ = service.observeRequestCompletion( + eventStream, + statusStream, + requestId, + ); + + expectObservable(asyncRequest$).toBe( + "7s #", + undefined, + new Error(StateEnum.EVENT_PROCESSING_TIMEOUT), + ); + + expectObservable(service.getRequestStatus$(requestId)).toBe( + "a-b-c-d 6993ms (e|)", + { + a: StateEnum.DORMANT, + b: StateEnum.INITIALIZING, + c: StateEnum.REQUESTING, + d: StateEnum.REQUEST_ACCEPTED, + e: StateEnum.EVENT_PROCESSING_TIMEOUT, + }, + ); + }); + }); + }); +}); diff --git a/src/app/services/notifications/asyncRequestStatus.service.ts b/src/app/services/notifications/asyncRequestStatus.service.ts new file mode 100644 index 0000000..b3c8af1 --- /dev/null +++ b/src/app/services/notifications/asyncRequestStatus.service.ts @@ -0,0 +1,175 @@ +import { Injectable } from "@angular/core"; +import { + BehaviorSubject, + catchError, + EMPTY, + filter, + finalize, + Observable, + race, + switchMap, + take, + tap, + throwError, + timeout, + TimeoutError, +} from "rxjs"; +import { StateEnum } from "../state/StateEnum"; +import { Event } from "../../model/events/event"; + +/** + * This service is responsible for managing the status of asynchronous requests. + * In this context, asynchronous requests are requests that are sent to a server, with their + * response expected in an event stream. This service tracks the state of the request through + * its lifecycle, and provides an observable that can be used to observe the request's status. + * + */ +@Injectable({ + providedIn: "root", +}) +export class AsyncRequestStatusService { + private readonly _requestStatuses: Map> = + new Map>(); + + public getRequestStatus$(eventId: string): Observable { + if (!this._requestStatuses.has(eventId)) { + return throwError( + () => new Error(`Request with id ${eventId} is not being processed`), + ); + } + + return this._requestStatuses.get(eventId)!.asObservable(); + } + + public observeRequestCompletion( + eventStream: Observable, + requestStatus$: Observable, + eventId: string, + ): Observable { + if (!this.putSink(eventId)) return EMPTY; + + const asyncRequestStatusSink = this._requestStatuses.get(eventId)!; + + this._requestStatuses.set(eventId, asyncRequestStatusSink); + + const requestResponseRace = this.requestResponseRace( + eventStream, + requestStatus$, + asyncRequestStatusSink, + eventId, + ); + + return requestResponseRace.pipe( + catchError((error) => this.handleRequestError(eventId, error)), + finalize(() => this.cleanUp(eventId)), + ); + } + + private putSink(eventId: string): boolean { + if (this._requestStatuses.has(eventId)) { + console.error(`Request with id ${eventId} is already being processed`); + return false; + } + + this._requestStatuses.set( + eventId, + new BehaviorSubject(StateEnum.DORMANT), + ); + + return true; + } + + private requestResponseRace( + eventStream: Observable, + requestStatus$: Observable, + asyncRequestStatusSink: BehaviorSubject, + eventId: string, + ): Observable { + const responseEvent$ = this.createEventResponseEventObserver( + eventStream, + eventId, + asyncRequestStatusSink, + ); + const requestEvent$ = this.createRequestStatusObserver( + requestStatus$, + responseEvent$, + asyncRequestStatusSink, + ); + + return race(requestEvent$, responseEvent$).pipe(timeout(7000)); + } + + private createEventResponseEventObserver( + eventStream: Observable, + eventId: string, + asyncRequestStatusSink: BehaviorSubject, + ): Observable { + return eventStream.pipe( + tap((event) => + console.debug(`Received event with id ${eventId}: `, event), + ), + filter((event) => event.eventId === eventId), + take(1), + tap((event) => { + asyncRequestStatusSink.next(StateEnum.EVENT_PROCESSED); + return event; + }), + ); + } + + private createRequestStatusObserver( + requestStatus$: Observable, + responseEvent$: Observable, + asyncRequestStatusSink: BehaviorSubject, + ): Observable { + const terminatingStatuses = [ + StateEnum.REQUEST_ACCEPTED, + StateEnum.REQUEST_COMPLETED, + StateEnum.REQUEST_REJECTED, + StateEnum.REQUEST_TIMEOUT, + ]; + + const terminatingErrorStatuses = [ + StateEnum.REQUEST_TIMEOUT, + StateEnum.REQUEST_REJECTED, + ]; + + return requestStatus$.pipe( + tap((status) => asyncRequestStatusSink.next(status)), + filter((status) => terminatingStatuses.includes(status)), + take(1), + tap((status) => { + if ( + asyncRequestStatusSink.getValue() !== StateEnum.EVENT_PROCESSED && + terminatingErrorStatuses.includes(status) + ) { + throw new Error(status); + } + }), + switchMap(() => responseEvent$), + ); + } + + private handleRequestError(eventId: string, error: Error) { + const asyncRequestStatusSink = this._requestStatuses.get(eventId)!; + console.error(`Error processing event with id ${eventId}: ${error}`); + + if (error instanceof TimeoutError) { + asyncRequestStatusSink.next(StateEnum.EVENT_PROCESSING_TIMEOUT); + } + + return throwError(() => new Error(asyncRequestStatusSink.getValue())); + } + + private cleanUp(eventId: string) { + const asyncRequestStatusSink = this._requestStatuses.get(eventId); + + if (!asyncRequestStatusSink) { + console.error(`Request with id ${eventId} is not being processed`); + return; + } + + this._requestStatuses.delete(eventId); + asyncRequestStatusSink.complete(); + } +} diff --git a/src/app/services/notifications/asynchronousRequest.mediator.spec.ts b/src/app/services/notifications/asynchronousRequest.mediator.spec.ts new file mode 100644 index 0000000..09aaaea --- /dev/null +++ b/src/app/services/notifications/asynchronousRequest.mediator.spec.ts @@ -0,0 +1,317 @@ +import { AsynchronousRequestMediator } from "./asynchronousRequest.mediator"; +import { TestBed } from "@angular/core/testing"; +import { ConnectorStatesEnum } from "../network/rsocket/ConnectorStatesEnum"; +import { BehaviorSubject } from "rxjs"; +import { RsocketService } from "../network/rsocket/rsocket.service"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { RetryOptions } from "../retry/retry.options"; +import { ConfigService } from "../../config/config.service"; +import { NotificationService } from "./notification.service"; +import { RsocketRequestFactory } from "../network/rsocket/rsocketRequest.factory"; +import { PrivateEventModel } from "../../model/events/privateEvent.model"; +import { v4 as uuidv4 } from "uuid"; +import { EventStatusEnum } from "../../model/enums/eventStatus.enum"; +import { EventTypeEnum } from "../../model/enums/eventType.enum"; +import { AggregateTypeEnum } from "../../model/enums/aggregateType.enum"; +import { MemberModel } from "../../model/member.model"; +import { MemberStatusEnum } from "../../model/enums/memberStatus.enum"; +import { GroupJoinRequestEvent } from "../../model/requestevent/GroupJoinRequestEvent"; +import { StateEnum } from "../state/StateEnum"; +import { UserService } from "../user/user.service"; + +/** + * This test is an integration test between AsynchronousRequestMediator and its dependencies. + * The only mocked dependency is RsocketRequestFactory, which returns observables that are backed + * by actual network connections. + */ +describe("AsynchronousRequestMediator", () => { + let service: AsynchronousRequestMediator; + + let connectionState$: BehaviorSubject; + let rsocketService: RsocketService; + let rsocketRequestFactoryMock: jasmine.SpyObj; + + let userService: UserService; + let notificationService: NotificationService; + + let testScheduler: TestScheduler; + let responseEvent: PrivateEventModel; + let requestEvent: GroupJoinRequestEvent; + + beforeEach(() => { + rsocketRequestFactoryMock = jasmine.createSpyObj( + "RsocketRequestFactory", + ["createRequestResponse", "createRequestStream"], + ); + + connectionState$ = new BehaviorSubject( + ConnectorStatesEnum.CONNECTED, + ); + + const requestEventId: string = uuidv4(); + + requestEvent = new GroupJoinRequestEvent( + requestEventId, + 1, + uuidv4(), + Date.now().toString(), + "Mojo", + ); + + const memberModel: MemberModel = new MemberModel( + 1, + "Mojo", + 1, + MemberStatusEnum.ACTIVE, + Date.now().toString(), + null, + ); + + responseEvent = new PrivateEventModel( + requestEventId, + 1, + uuidv4(), + AggregateTypeEnum.GROUP, + EventTypeEnum.MEMBER_JOINED, + memberModel, + EventStatusEnum.SUCCESSFUL, + Date.now().toString(), + ); + + const retryOptions: RetryOptions = { + MAX_ATTEMPTS: 1, + MIN_RETRY_INTERVAL: 5, + MAX_RETRY_INTERVAL: 5, + }; + + TestBed.configureTestingModule({ + providers: [ + AsynchronousRequestMediator, + { + provide: ConfigService, + useValue: { + get retryDefaultStrategy(): RetryOptions { + return retryOptions; + }, + }, + }, + { + provide: RsocketRequestFactory, + useValue: rsocketRequestFactoryMock, + }, + ], + }); + + userService = TestBed.inject(UserService); + + rsocketService = TestBed.inject(RsocketService); + spyOnProperty(rsocketService, "connectionState$", "get").and.returnValue( + connectionState$.asObservable(), + ); + + service = TestBed.inject(AsynchronousRequestMediator); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, "showMessage"); + }); + + describe("submitRequestEvent", () => { + describe("when the request is successful", () => { + it("should process the response event when a response is received", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + + const requestRoute = "request.route"; + const eventResponseRoute = "response.route"; + + // The backend only sends us properties, so use the spread operator to only send back properties, not methods + const eventResponses$ = cold("----a", { a: { ...responseEvent } }); + + const request$ = cold("--|"); + + rsocketRequestFactoryMock.createRequestStream.and.returnValue( + eventResponses$, + ); + + rsocketRequestFactoryMock.createRequestResponse.and.returnValue( + request$, + ); + + const response$ = service.submitRequestEvent( + requestEvent, + requestRoute, + eventResponseRoute, + ); + + expectObservable(response$).toBe("a-b-(c|)", { + a: StateEnum.REQUESTING, + b: StateEnum.REQUEST_COMPLETED, + c: StateEnum.EVENT_PROCESSED, + }); + + flush(); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Successfully joined group as Mojo!", + ); + }); + }); + + it("should handle the response event timeout error when no response event is received", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const requestRoute = "request.route"; + const eventResponseRoute = "response.route"; + + const eventResponses$ = cold("-"); + const request$ = cold("--|"); + + rsocketRequestFactoryMock.createRequestStream.and.returnValue( + eventResponses$, + ); + + rsocketRequestFactoryMock.createRequestResponse.and.returnValue( + request$, + ); + + const response$ = service.submitRequestEvent( + requestEvent, + requestRoute, + eventResponseRoute, + ); + + expectObservable(response$).toBe("a-b- 6996ms (c|)", { + a: StateEnum.REQUESTING, + b: StateEnum.REQUEST_COMPLETED, + c: StateEnum.EVENT_PROCESSING_TIMEOUT, + }); + }); + }); + }); + + describe("when the request is unsuccessful", () => { + it("should handle request rejected errors", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + + const requestRoute = "request.route"; + const eventResponseRoute = "response.route"; + + const eventResponses$ = cold("-"); + const request$ = cold("--#"); + + rsocketRequestFactoryMock.createRequestStream.and.returnValue( + eventResponses$, + ); + + rsocketRequestFactoryMock.createRequestResponse.and.returnValue( + request$, + ); + + const response$ = service.submitRequestEvent( + requestEvent, + requestRoute, + eventResponseRoute, + ); + + expectObservable(response$).toBe("a-(b|)", { + a: StateEnum.REQUESTING, + b: StateEnum.REQUEST_REJECTED, + }); + + flush(); + + expect(notificationService.showMessage).toHaveBeenCalledWith( + `Error submitting request: ${StateEnum.REQUEST_REJECTED}`, + ); + }); + }); + + it("should handle request timeout errors", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + + const requestRoute = "request.route"; + const eventResponseRoute = "response.route"; + + const eventResponses$ = cold("-"); + const request$ = cold("-"); + + rsocketRequestFactoryMock.createRequestStream.and.returnValue( + eventResponses$, + ); + + rsocketRequestFactoryMock.createRequestResponse.and.returnValue( + request$, + ); + + const response$ = service.submitRequestEvent( + requestEvent, + requestRoute, + eventResponseRoute, + ); + + expectObservable(response$).toBe("a- 4998ms (b|)", { + a: StateEnum.REQUESTING, + b: StateEnum.REQUEST_TIMEOUT, + }); + + flush(); + + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Server is not responding. Try again?", + ); + }); + }); + }); + + describe("when a response is received before the request has completed", () => { + it("should handle the response event and complete", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable, flush } = helpers; + + const requestRoute = "request.route"; + const eventResponseRoute = "response.route"; + + // The backend only sends us properties, so use the spread operator to only send back properties, not methods + const eventResponses$ = cold("----a", { a: { ...responseEvent } }); + const request$ = cold("-"); + + rsocketRequestFactoryMock.createRequestStream.and.returnValue( + eventResponses$, + ); + + rsocketRequestFactoryMock.createRequestResponse.and.returnValue( + request$, + ); + + const response$ = service.submitRequestEvent( + requestEvent, + requestRoute, + eventResponseRoute, + ); + + expectObservable(response$).toBe("a---(b|)", { + a: StateEnum.REQUESTING, + b: StateEnum.EVENT_PROCESSED, + }); + + flush(); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Successfully joined group as Mojo!", + ); + }); + }); + }); + }); +}); diff --git a/src/app/services/notifications/asynchronousRequest.mediator.ts b/src/app/services/notifications/asynchronousRequest.mediator.ts new file mode 100644 index 0000000..fff2859 --- /dev/null +++ b/src/app/services/notifications/asynchronousRequest.mediator.ts @@ -0,0 +1,88 @@ +import { RsocketRequestMediatorFactory } from "../network/rsocket/mediators/rsocketRequestMediator.factory"; +import { NotificationService } from "./notification.service"; +import { Injectable } from "@angular/core"; +import { RequestEvent } from "../../model/requestevent/RequestEvent"; +import { map, Observable } from "rxjs"; +import { StateEnum } from "../state/StateEnum"; +import { GroupEventVisitor } from "./visitors/group/groupEvent.visitor"; +import { EventStreamService } from "./eventStream.service"; +import { AsyncRequestStatusService } from "./asyncRequestStatus.service"; +import { Event } from "../../model/events/event"; +import { EventRevivable } from "../../model/events/event.revivable"; + +/** + * This service submits asynchronous RSocket requests and handles their response. + */ +@Injectable({ + providedIn: "root", +}) +export class AsynchronousRequestMediator { + constructor( + private readonly eventStreamService: EventStreamService, + private readonly asyncRequestStatusService: AsyncRequestStatusService, + private readonly rsocketRequestFactory: RsocketRequestMediatorFactory, + private readonly notificationService: NotificationService, + private readonly groupEventVisitor: GroupEventVisitor, + ) {} + + public submitRequestEvent( + requestEvent: RequestEvent, + requestRoute: string, + responseRoute: string, + ): Observable { + const eventStream = this.eventStreamService.stream(responseRoute); + const request = this.rsocketRequestFactory.createRequestResponseMediator< + RequestEvent, + unknown + >(requestRoute, requestEvent); + + this.asyncRequestStatusService + .observeRequestCompletion( + eventStream, + request.getState$(true), + requestEvent.eventId, + ) + .pipe(map((event) => EventRevivable.createEvent(event))) + .subscribe({ + next: (event) => { + event.accept(this.groupEventVisitor); + }, + error: (error) => { + console.error(`Error processing event: ${error.message}`); + switch (error.message) { + case StateEnum.REQUEST_TIMEOUT: + this.handleRequestTimeoutError(); + break; + case StateEnum.EVENT_PROCESSING_TIMEOUT: + this.handleResponseTimeoutError(); + break; + default: + this.handleRequestRejectedError(error); + break; + } + }, + }); + + return this.asyncRequestStatusService.getRequestStatus$( + requestEvent.eventId, + ); + } + + private handleRequestTimeoutError() { + this.notificationService.showMessage( + "Server is not responding. Try again?", + ); + } + + private handleRequestRejectedError(error: Error) { + this.notificationService.showMessage( + `Error submitting request: ${error.message}`, + ); + } + + private handleResponseTimeoutError() { + this.notificationService.showMessage( + "Your request has been accepted, but the server is still processing it. Try again?", + ); + } +} diff --git a/src/app/services/notifications/eventStream.service.spec.ts b/src/app/services/notifications/eventStream.service.spec.ts new file mode 100644 index 0000000..baa0b29 --- /dev/null +++ b/src/app/services/notifications/eventStream.service.spec.ts @@ -0,0 +1,96 @@ +import { EventStreamService } from "./eventStream.service"; +import { TestBed } from "@angular/core/testing"; +import { RsocketRequestMediatorFactory } from "../network/rsocket/mediators/rsocketRequestMediator.factory"; +import { RequestServiceComponentInterface } from "../network/rsocket/mediators/interfaces/requestServiceComponent.interface"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; +import { EMPTY, of } from "rxjs"; +import { ConfigService } from "../../config/config.service"; +import { StateEnum } from "../state/StateEnum"; + +describe("EventStreamService", () => { + let service: EventStreamService; + let rsocketRequestFactory: RsocketRequestMediatorFactory; + let rsocketServiceComponentInterface: RequestServiceComponentInterface; + let testScheduler: TestScheduler; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + EventStreamService, + { + provide: ConfigService, + useValue: {}, + }, + ], + }); + + service = TestBed.inject(EventStreamService); + rsocketRequestFactory = TestBed.inject(RsocketRequestMediatorFactory); + rsocketServiceComponentInterface = + rsocketRequestFactory.createStreamMediator("route"); + + spyOn(rsocketRequestFactory, "createStreamMediator").and.returnValue( + rsocketServiceComponentInterface, + ); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + spyOn(rsocketServiceComponentInterface, "getEvents$").and.returnValue( + of(EMPTY), + ); + }); + + describe("#stream", () => { + it("should return the same stream if it has already been created", () => { + const streamObservableA = service.stream("route"); + const streamObservableB = service.stream("route"); + + expect(streamObservableA).toBe(streamObservableB); + }); + }); + + describe("#streamStatus", () => { + it("should return the status of an existing stream with the given route", () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + + service.stream("route"); + const streamStatusObservable = service.streamStatus("route"); + + expectObservable(streamStatusObservable).toBe("a", { + a: StateEnum.DORMANT, + }); + }); + }); + + it("should throw an error if there is no stream with the given route", () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + + const streamStatusObservable = service.streamStatus("nonTrackedStream"); + + expectObservable(streamStatusObservable).toBe( + "#", + null, + new Error("No stream status for response route: nonTrackedStream"), + ); + }); + }); + }); + + describe("#retryTime", () => { + it("should throw an error if there is no stream with the given route", () => { + expect(() => service.retryTime("nonTrackedStream")).toThrowError( + "No stream status for response route: nonTrackedStream", + ); + }); + + it("should return the retry time of an existing stream with the given route", () => { + service.stream("route"); + expect(() => service.retryTime("route")).not.toThrowError(); + expect(service.retryTime("route")).toBeUndefined(); + }); + }); +}); diff --git a/src/app/services/notifications/eventStream.service.ts b/src/app/services/notifications/eventStream.service.ts new file mode 100644 index 0000000..e3d2614 --- /dev/null +++ b/src/app/services/notifications/eventStream.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from "@angular/core"; +import { RequestServiceComponentInterface } from "../network/rsocket/mediators/interfaces/requestServiceComponent.interface"; +import { RsocketRequestMediatorFactory } from "../network/rsocket/mediators/rsocketRequestMediator.factory"; +import { Observable, throwError } from "rxjs"; +import { StateEnum } from "../state/StateEnum"; + +/** + * This service is responsible for managing event streams. + * It provides a method to subscribe to a stream of events, creating a new stream if necessary. + * It also keeps track of the streams it has created, so that it can return the same stream if it is requested again. + * Streams are backed by {@link AbstractRsocketRequestMediator} instances, which return shareable observables for + * events and request statuses. + */ +@Injectable({ + providedIn: "root", +}) +export class EventStreamService { + private readonly _eventStreams: Map< + string, + RequestServiceComponentInterface + >; + + constructor( + private readonly rsocketRequestFactory: RsocketRequestMediatorFactory, + ) { + this._eventStreams = new Map< + string, + RequestServiceComponentInterface + >(); + } + + public stream(route: string): Observable { + const savedStream = this._eventStreams.get(route); + + if (savedStream) { + return savedStream.getEvents$(); + } + + const newStream = this.rsocketRequestFactory.createStreamMediator< + unknown, + T + >(route, null); + this._eventStreams.set(route, newStream); + + return newStream.getEvents$(true); + } + + public streamStatus(route: string): Observable { + if (!this._eventStreams.has(route)) { + return throwError( + () => new Error(`No stream status for response route: ${route}`), + ); + } + + return this._eventStreams.get(route)!.getState$(); + } + + public retryTime(route: string): Observable | undefined { + if (!this._eventStreams.has(route)) { + throw new Error(`No stream status for response route: ${route}`); + } + + return this._eventStreams.get(route)!.nextRetryTime$; + } +} diff --git a/src/app/services/notifications/eventhandlers/eventDataValidators.ts b/src/app/services/notifications/eventhandlers/eventDataValidators.ts new file mode 100644 index 0000000..cdae5cf --- /dev/null +++ b/src/app/services/notifications/eventhandlers/eventDataValidators.ts @@ -0,0 +1,68 @@ +import { EventDataModel } from "../../../model/events/eventDataModel"; +import { MemberModel } from "../../../model/member.model"; +import { ErrorDataModel } from "../../../model/errorData.model"; +import { GroupModel } from "../../../model/group.model"; + +export function isEventDataGroupModel( + eventData: EventDataModel, +): eventData is GroupModel { + return ( + eventData !== null && + typeof eventData === "object" && + "id" in eventData && + typeof eventData.id === "number" && + "title" in eventData && + typeof eventData.title === "string" && + "description" in eventData && + typeof eventData.description === "string" && + "maxGroupSize" in eventData && + typeof eventData.maxGroupSize === "number" && + "createdDate" in eventData && + typeof eventData.createdDate === "string" && + "lastModifiedDate" in eventData && + typeof eventData.lastModifiedDate === "string" && + "createdBy" in eventData && + typeof eventData.createdBy === "string" && + "lastModifiedBy" in eventData && + typeof eventData.lastModifiedBy === "string" && + "version" in eventData && + typeof eventData.version === "number" && + "status" in eventData && + typeof eventData.status === "string" && + "members" in eventData && + Array.isArray(eventData.members) && + eventData.members.every((member) => isEventDataMemberModel(member)) + ); +} + +export function isEventDataMemberModel( + eventData: EventDataModel, +): eventData is MemberModel { + return ( + eventData !== null && + typeof eventData === "object" && + "id" in eventData && + typeof eventData.id === "number" && + "username" in eventData && + typeof eventData.username === "string" && + "groupId" in eventData && + typeof eventData.groupId === "number" && + "memberStatus" in eventData && + typeof eventData.memberStatus === "string" && + "joinedDate" in eventData && + typeof eventData.joinedDate === "string" && + "exitedDate" in eventData && + (typeof eventData.exitedDate === "string" || eventData.exitedDate === null) + ); +} + +export function isEventDataErrorModel( + eventData: EventDataModel, +): eventData is ErrorDataModel { + return ( + eventData !== null && + typeof eventData === "object" && + "error" in eventData && + typeof eventData.error !== "object" + ); +} diff --git a/src/app/services/notifications/eventhandlers/eventHandler.ts b/src/app/services/notifications/eventhandlers/eventHandler.ts new file mode 100644 index 0000000..76098e9 --- /dev/null +++ b/src/app/services/notifications/eventhandlers/eventHandler.ts @@ -0,0 +1,7 @@ +import { PublicEventModel } from "../../../model/events/publicEvent.model"; +import { PrivateEventModel } from "../../../model/events/privateEvent.model"; + +export interface EventHandler { + handlePublicEvent(event: PublicEventModel): void; + handlePrivateEvent(event: PrivateEventModel): void; +} diff --git a/src/app/services/notifications/eventhandlers/groupEventHandlers/groupUpdated.handler.spec.ts b/src/app/services/notifications/eventhandlers/groupEventHandlers/groupUpdated.handler.spec.ts new file mode 100644 index 0000000..a30c09b --- /dev/null +++ b/src/app/services/notifications/eventhandlers/groupEventHandlers/groupUpdated.handler.spec.ts @@ -0,0 +1,163 @@ +import { EventHandler } from "../eventHandler"; +import { GroupUpdatedHandler } from "./groupUpdated.handler"; +import { TestBed } from "@angular/core/testing"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; +import { UserService } from "../../../user/user.service"; +import { NotificationService } from "../../notification.service"; +import { EventStatusEnum } from "../../../../model/enums/eventStatus.enum"; +import { GroupStatusEnum } from "../../../../model/enums/groupStatus.enum"; +import { GroupModel } from "../../../../model/group.model"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; + +describe("GroupUpdatedHandler", () => { + let groupUpdatedHandler: EventHandler; + let userService: UserService; + let notificationService: NotificationService; + + let privateEvent: PrivateEventModel; + let publicEvent: PublicEventModel; + let eventData: GroupModel; + + beforeEach(() => { + eventData = new GroupModel( + 1, + "title", + "description", + 5, + Date.now().toString(), + Date.now().toString(), + "owner", + "owner", + 1, + GroupStatusEnum.DISBANDED, + [], + ); + + privateEvent = {} as PrivateEventModel; + + publicEvent = {} as PublicEventModel; + + TestBed.configureTestingModule({ + providers: [GroupUpdatedHandler], + }); + + groupUpdatedHandler = TestBed.inject(GroupUpdatedHandler); + userService = TestBed.inject(UserService); + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, "showMessage"); + }); + + describe("handlePrivateEvent", () => { + it("should throw an error since private group update events are currently unsupported", () => { + expect(() => + groupUpdatedHandler.handlePrivateEvent(privateEvent), + ).toThrowError("Unsupported private group update event"); + }); + }); + + describe("handlePublicEvent", () => { + describe("unsuccessful event", () => { + beforeEach(() => { + publicEvent.eventStatus = EventStatusEnum.FAILED; + }); + + it("should not take any action if the event is unsuccessful", () => { + groupUpdatedHandler.handlePublicEvent(publicEvent); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + + describe("successful event", () => { + beforeEach(() => { + publicEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + }); + + describe("user in updated group", () => { + beforeEach(() => { + userService.setUserInGroup(1, 1); + publicEvent.aggregateId = 1; + }); + + it("should set the user's currentGroupId and currentMemberId to null if their group is disbanded", () => { + const disbandedStatuses = [ + GroupStatusEnum.BANNED, + GroupStatusEnum.AUTO_DISBANDED, + GroupStatusEnum.DISBANDED, + ]; + + for (const status of disbandedStatuses) { + eventData.status = status; + publicEvent.eventData = eventData; + groupUpdatedHandler.handlePublicEvent(publicEvent); + expect(userService.currentGroupId).toBeNull(); + expect(userService.currentMemberId).toBeNull(); + } + }); + + describe("notifications", () => { + it("should display a notification if the user's group has been banned", () => { + eventData.status = GroupStatusEnum.BANNED; + publicEvent.eventData = eventData; + groupUpdatedHandler.handlePublicEvent(publicEvent); + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Your group has been banned", + ); + }); + + it("should display a notification if the user's group has expired and has been disbanded", () => { + eventData.status = GroupStatusEnum.AUTO_DISBANDED; + publicEvent.eventData = eventData; + groupUpdatedHandler.handlePublicEvent(publicEvent); + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Your group has expired and has been disbanded", + ); + }); + + it("should display a notification if the user's group has been disbanded by the owner", () => { + eventData.status = GroupStatusEnum.DISBANDED; + publicEvent.eventData = eventData; + groupUpdatedHandler.handlePublicEvent(publicEvent); + expect(notificationService.showMessage).toHaveBeenCalledWith( + "Your group has been disbanded by the owner", + ); + }); + }); + }); + + describe("user not in updated group", () => { + beforeEach(() => { + userService.setUserInGroup(2, 1); + publicEvent.aggregateId = 1; + }); + + it("should not set the user's currentGroupId and currentMemberId to null if the group has been disbanded", () => { + publicEvent.eventData = { + ...eventData, + status: GroupStatusEnum.DISBANDED, + }; + groupUpdatedHandler.handlePublicEvent(publicEvent); + expect(userService.currentGroupId).toBe(2); + expect(userService.currentMemberId).toBe(1); + }); + }); + }); + + describe("invalid event data", () => { + it("should not take any action and throw an error if the if the event data is invalid", () => { + userService.setUserInGroup(1, 1); + + publicEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + + publicEvent.eventData = {}; + + expect(() => + groupUpdatedHandler.handlePublicEvent(publicEvent), + ).toThrowError(); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/services/notifications/eventhandlers/groupEventHandlers/groupUpdated.handler.ts b/src/app/services/notifications/eventhandlers/groupEventHandlers/groupUpdated.handler.ts new file mode 100644 index 0000000..9af21e5 --- /dev/null +++ b/src/app/services/notifications/eventhandlers/groupEventHandlers/groupUpdated.handler.ts @@ -0,0 +1,85 @@ +import { Injectable } from "@angular/core"; +import { EventHandler } from "../eventHandler"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; +import { UserService } from "../../../user/user.service"; +import { NotificationService } from "../../notification.service"; +import { GroupModel } from "../../../../model/group.model"; +import { EventStatusEnum } from "../../../../model/enums/eventStatus.enum"; +import { isEventDataGroupModel } from "../eventDataValidators"; +import { GroupStatusEnum } from "../../../../model/enums/groupStatus.enum"; +import { GroupsService } from "../../../../groups/services/groups.service"; + +@Injectable({ + providedIn: "root", +}) +export class GroupUpdatedHandler implements EventHandler { + constructor( + private readonly groupService: GroupsService, + private readonly userService: UserService, + private readonly notificationService: NotificationService, + ) {} + + handlePrivateEvent(event: PrivateEventModel): void { + console.warn( + "Private group update events are currently unsupported", + event, + ); + throw new Error("Unsupported private group update event"); + } + + handlePublicEvent(event: PublicEventModel): void { + switch (event.eventStatus) { + case EventStatusEnum.SUCCESSFUL: + this.publicEventSuccess(event); + break; + case EventStatusEnum.FAILED: + console.debug( + "Public update group failed event received. No action taken.", + ); + break; + default: + console.warn( + "Invalid event status for public join group event: ", + event, + ); + break; + } + } + + private publicEventSuccess(event: PublicEventModel): void { + if (!isEventDataGroupModel(event.eventData)) { + throw new Error( + "Invalid event data for public update group successful event", + ); + } + const updatedGroup: GroupModel = event.eventData as GroupModel; + + this.groupService.handleGroupUpdate(updatedGroup); + + this.removeMemberIfInDisbandedGroup(updatedGroup); + } + + private removeMemberIfInDisbandedGroup(updatedGroup: GroupModel): void { + if (this.userService.currentGroupId !== updatedGroup.id) return; + + if (updatedGroup.status !== GroupStatusEnum.ACTIVE) + this.userService.removeUserFromGroup(); + + switch (updatedGroup.status) { + case GroupStatusEnum.BANNED: + this.notificationService.showMessage(`Your group has been banned`); + break; + case GroupStatusEnum.AUTO_DISBANDED: + this.notificationService.showMessage( + `Your group has expired and has been disbanded`, + ); + break; + case GroupStatusEnum.DISBANDED: + this.notificationService.showMessage( + `Your group has been disbanded by the owner`, + ); + break; + } + } +} diff --git a/src/app/services/notifications/eventhandlers/groupEventHandlers/joinGroup.handler.spec.ts b/src/app/services/notifications/eventhandlers/groupEventHandlers/joinGroup.handler.spec.ts new file mode 100644 index 0000000..c0310a0 --- /dev/null +++ b/src/app/services/notifications/eventhandlers/groupEventHandlers/joinGroup.handler.spec.ts @@ -0,0 +1,190 @@ +import { JoinGroupHandler } from "./joinGroup.handler"; +import { UserService } from "../../../user/user.service"; +import { NotificationService } from "../../notification.service"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; +import { TestBed } from "@angular/core/testing"; +import { EventStatusEnum } from "../../../../model/enums/eventStatus.enum"; +import { MemberModel } from "../../../../model/member.model"; +import { MemberStatusEnum } from "../../../../model/enums/memberStatus.enum"; +import { ErrorDataModel } from "../../../../model/errorData.model"; + +describe("JoinGroupHandler", () => { + let joinGroupHandler: JoinGroupHandler; + let userService: UserService; + let notificationService: NotificationService; + + let privateEvent: PrivateEventModel; + let publicEvent: PublicEventModel; + let memberModel: MemberModel; + + beforeEach(() => { + privateEvent = {} as PrivateEventModel; + + publicEvent = {} as PublicEventModel; + + memberModel = new MemberModel( + 1, + "User", + 1, + MemberStatusEnum.ACTIVE, + Date.now().toString(), + null, + ); + + TestBed.configureTestingModule({ + providers: [JoinGroupHandler], + }); + + joinGroupHandler = TestBed.inject(JoinGroupHandler); + userService = TestBed.inject(UserService); + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, "showMessage"); + + userService.removeUserFromGroup(); + }); + + describe("handlePrivateEvent", () => { + describe("successful event", () => { + beforeEach(() => { + privateEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + privateEvent.aggregateId = 1; + }); + + describe("user joining group", () => { + it("should set the current group and member id and show a message", () => { + privateEvent.eventData = memberModel; + + joinGroupHandler.handlePrivateEvent(privateEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).toHaveBeenCalledWith( + `Successfully joined group as ${memberModel.username}!`, + ); + }); + + it("should not take any action if the user is already in the group", () => { + userService.setUserInGroup(1, 1); + + privateEvent.eventData = memberModel; + + joinGroupHandler.handlePrivateEvent(privateEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + }); + + describe("unsuccessful event", () => { + beforeEach(() => { + privateEvent.eventStatus = EventStatusEnum.FAILED; + }); + + it("should not change the user's group and member id", () => { + const errorDataModel = new ErrorDataModel("Group is full"); + privateEvent.eventData = errorDataModel; + + joinGroupHandler.handlePrivateEvent(privateEvent); + + expect(userService.currentGroupId).toBe(null); + expect(userService.currentMemberId).toBe(null); + expect(notificationService.showMessage).toHaveBeenCalledWith( + `Error joining group: ${errorDataModel.error}`, + ); + }); + }); + + describe("invalid event data", () => { + it("should not take any action and throw an error if the event data is invalid", () => { + const eventStatuses = [ + EventStatusEnum.SUCCESSFUL, + EventStatusEnum.FAILED, + ]; + + for (const eventStatus of eventStatuses) { + privateEvent.eventStatus = eventStatus; + privateEvent.eventData = {}; + + expect(() => + joinGroupHandler.handlePrivateEvent(privateEvent), + ).toThrowError(); + + expect(userService.currentGroupId).toBe(null); + expect(userService.currentMemberId).toBe(null); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + } + }); + }); + }); + + describe("handlePublicEvent", () => { + beforeEach(() => { + userService.setUserInGroup(1, 1); + }); + + describe("successful event", () => { + beforeEach(() => { + publicEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + publicEvent.aggregateId = 1; + }); + + describe("user joining group", () => { + it("should show a message that a user has joined the group if the current user is part of the group and is not the one who joined", () => { + memberModel.id = 2; + publicEvent.eventData = memberModel; + + joinGroupHandler.handlePublicEvent(publicEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).toHaveBeenCalledWith( + `${memberModel.username} joined the group!`, + ); + }); + + it("should not show a message if the same user joined the group", () => { + publicEvent.aggregateId = 1; + publicEvent.eventData = memberModel; + + joinGroupHandler.handlePublicEvent(publicEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + }); + + describe("unsuccessful event", () => { + beforeEach(() => { + publicEvent.eventStatus = EventStatusEnum.FAILED; + }); + + it("should not take any action if event is unsuccessful", () => { + joinGroupHandler.handlePublicEvent(publicEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + + describe("invalid event data", () => { + it("should not take any action and throw an error if the event data is invalid", () => { + publicEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + publicEvent.eventData = {}; + + expect(() => + joinGroupHandler.handlePublicEvent(publicEvent), + ).toThrowError(); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/services/notifications/eventhandlers/groupEventHandlers/joinGroup.handler.ts b/src/app/services/notifications/eventhandlers/groupEventHandlers/joinGroup.handler.ts new file mode 100644 index 0000000..22b315c --- /dev/null +++ b/src/app/services/notifications/eventhandlers/groupEventHandlers/joinGroup.handler.ts @@ -0,0 +1,122 @@ +import { UserService } from "../../../user/user.service"; +import { NotificationService } from "../../notification.service"; +import { EventHandler } from "../eventHandler"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; +import { MemberModel } from "../../../../model/member.model"; +import { Injectable } from "@angular/core"; +import { EventStatusEnum } from "../../../../model/enums/eventStatus.enum"; +import { ErrorDataModel } from "../../../../model/errorData.model"; +import { + isEventDataErrorModel, + isEventDataMemberModel, +} from "../eventDataValidators"; +import { GroupsService } from "../../../../groups/services/groups.service"; + +@Injectable({ + providedIn: "root", +}) +export class JoinGroupHandler implements EventHandler { + constructor( + private readonly groupService: GroupsService, + private readonly userService: UserService, + private readonly notificationService: NotificationService, + ) {} + + handlePrivateEvent(event: PrivateEventModel): void { + switch (event.eventStatus) { + case EventStatusEnum.SUCCESSFUL: + this.privateEventSuccess(event); + break; + case EventStatusEnum.FAILED: + this.privateEventFailure(event); + break; + default: + console.warn( + "Invalid event status for private join group event: ", + event, + ); + break; + } + } + + private privateEventSuccess(event: PrivateEventModel): void { + if (!isEventDataMemberModel(event.eventData)) { + throw new Error( + "Invalid event data for private join group successful event", + ); + } + + const joinedMember: MemberModel = event.eventData as MemberModel; + const groupId = event.aggregateId; + + if (this.userService.currentGroupId === groupId) return; + + this.userService.setUserInGroup(groupId, joinedMember.id); + console.log("Current group id: ", this.userService.currentGroupId); + this.notificationService.showMessage( + `Successfully joined group as ${joinedMember.username}!`, + ); + } + + private privateEventFailure(event: PrivateEventModel): void { + if (!isEventDataErrorModel(event.eventData)) { + throw new Error("Invalid event data for private join group failed event"); + } + + const errorData: ErrorDataModel = event.eventData as ErrorDataModel; + this.notificationService.showMessage( + `Error joining group: ${errorData.error}`, + ); + } + + handlePublicEvent(event: PublicEventModel): void { + switch (event.eventStatus) { + case EventStatusEnum.SUCCESSFUL: + this.publicEventSuccess(event); + break; + case EventStatusEnum.FAILED: + console.debug( + "Public join group failed event received. No action taken.", + ); + break; + default: + console.warn( + "Invalid event status for public join group event: ", + event, + ); + break; + } + } + + private publicEventSuccess(event: PublicEventModel): void { + if (!isEventDataMemberModel(event.eventData)) { + throw new Error( + "Invalid event data for public join group successful event", + ); + } + + const joinedMember: MemberModel = event.eventData as MemberModel; + + this.groupService.addMember(joinedMember, event.aggregateId); + + this.showJoinMessageIfInGroupAndNotSelf( + event.aggregateId, + joinedMember.id, + joinedMember.username, + ); + } + + private showJoinMessageIfInGroupAndNotSelf( + groupId: number, + memberId: number, + memberName: string, + ) { + if ( + this.userService.currentGroupId === groupId && + this.userService.currentMemberId !== memberId + ) { + this.notificationService.showMessage(`${memberName} joined the group!`); + } + } +} diff --git a/src/app/services/notifications/eventhandlers/groupEventHandlers/leaveGroup.handler.spec.ts b/src/app/services/notifications/eventhandlers/groupEventHandlers/leaveGroup.handler.spec.ts new file mode 100644 index 0000000..d58578c --- /dev/null +++ b/src/app/services/notifications/eventhandlers/groupEventHandlers/leaveGroup.handler.spec.ts @@ -0,0 +1,204 @@ +import { LeaveGroupHandler } from "./leaveGroup.handler"; +import { UserService } from "../../../user/user.service"; +import { NotificationService } from "../../notification.service"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; +import { TestBed } from "@angular/core/testing"; +import { EventStatusEnum } from "../../../../model/enums/eventStatus.enum"; +import { MemberModel } from "../../../../model/member.model"; +import { MemberStatusEnum } from "../../../../model/enums/memberStatus.enum"; +import { ErrorDataModel } from "../../../../model/errorData.model"; + +describe("LeaveGroupHandler", () => { + let leaveGroupHandler: LeaveGroupHandler; + let userService: UserService; + let notificationService: NotificationService; + + let privateEvent: PrivateEventModel; + let publicEvent: PublicEventModel; + let memberModel: MemberModel; + + beforeEach(() => { + privateEvent = {} as PrivateEventModel; + + publicEvent = {} as PublicEventModel; + + memberModel = new MemberModel( + 1, + "User", + 1, + MemberStatusEnum.ACTIVE, + Date.now().toString(), + null, + ); + + TestBed.configureTestingModule({ + providers: [LeaveGroupHandler], + }); + + leaveGroupHandler = TestBed.inject(LeaveGroupHandler); + userService = TestBed.inject(UserService); + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, "showMessage"); + + userService.removeUserFromGroup(); + }); + + describe("handlePrivateEvent", () => { + describe("successful event", () => { + beforeEach(() => { + privateEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + privateEvent.aggregateId = 1; + }); + + describe("user leaving group", () => { + it("should clear the current group and member id if the user is in the group and show a message", () => { + userService.setUserInGroup(1, 1); + privateEvent.eventData = memberModel; + + leaveGroupHandler.handlePrivateEvent(privateEvent); + + expect(userService.currentGroupId).toBeNull(); + expect(userService.currentMemberId).toBeNull(); + expect(notificationService.showMessage).toHaveBeenCalledWith( + `Successfully left group as ${memberModel.username}!`, + ); + }); + + it("should not take any action if the user is not in the group", () => { + userService.setUserInGroup(2, 2); + privateEvent.eventData = memberModel; + + leaveGroupHandler.handlePrivateEvent(privateEvent); + + expect(userService.currentGroupId).toBe(2); + expect(userService.currentMemberId).toBe(2); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + }); + + describe("unsuccessful event", () => { + beforeEach(() => { + privateEvent.eventStatus = EventStatusEnum.FAILED; + }); + + it("should not change the user's group and member id", () => { + userService.setUserInGroup(1, 1); + const errorDataModel = new ErrorDataModel("Group is not active"); + privateEvent.eventData = errorDataModel; + + leaveGroupHandler.handlePrivateEvent(privateEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).toHaveBeenCalledWith( + `Error leaving group: ${errorDataModel.error}`, + ); + }); + }); + + describe("invalid event data", () => { + it("should not take any action and throw an error if the event data is invalid", () => { + userService.setUserInGroup(1, 1); + + const eventStatuses = [ + EventStatusEnum.SUCCESSFUL, + EventStatusEnum.FAILED, + ]; + + for (const eventStatus of eventStatuses) { + privateEvent.eventStatus = eventStatus; + + privateEvent.eventData = {}; + + expect(() => + leaveGroupHandler.handlePrivateEvent(privateEvent), + ).toThrowError(); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + } + }); + }); + }); + + describe("handlePublicEvent", () => { + describe("successful event", () => { + beforeEach(() => { + publicEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + publicEvent.aggregateId = 1; + userService.setUserInGroup(1, 1); + }); + + describe("user in updated group", () => { + it("should show a message to the user if someone in their group (excluding themselves) left", () => { + memberModel.id = 2; + publicEvent.eventData = memberModel; + + leaveGroupHandler.handlePublicEvent(publicEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).toHaveBeenCalledWith( + `${memberModel.username} left the group`, + ); + }); + + it("should not take any action if the user left the group", () => { + publicEvent.eventData = memberModel; + + leaveGroupHandler.handlePublicEvent(publicEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + + it("should not take any action and throw an error if the event data is invalid", () => { + publicEvent.eventData = {}; + + expect(() => + leaveGroupHandler.handlePublicEvent(publicEvent), + ).toThrowError(); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + }); + + describe("unsuccessful event", () => { + it("should not take any action if the event is unsuccessful", () => { + publicEvent.eventStatus = EventStatusEnum.FAILED; + userService.setUserInGroup(1, 1); + + leaveGroupHandler.handlePublicEvent(publicEvent); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + + describe("invalid event data", () => { + it("should not take any action and throw an error if the event data is invalid", () => { + userService.setUserInGroup(1, 1); + + publicEvent.eventStatus = EventStatusEnum.SUCCESSFUL; + + publicEvent.eventData = {}; + + expect(() => + leaveGroupHandler.handlePublicEvent(publicEvent), + ).toThrowError(); + + expect(userService.currentGroupId).toBe(1); + expect(userService.currentMemberId).toBe(1); + expect(notificationService.showMessage).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/services/notifications/eventhandlers/groupEventHandlers/leaveGroup.handler.ts b/src/app/services/notifications/eventhandlers/groupEventHandlers/leaveGroup.handler.ts new file mode 100644 index 0000000..ef6c0bd --- /dev/null +++ b/src/app/services/notifications/eventhandlers/groupEventHandlers/leaveGroup.handler.ts @@ -0,0 +1,122 @@ +import { Injectable } from "@angular/core"; +import { EventHandler } from "../eventHandler"; +import { UserService } from "../../../user/user.service"; +import { NotificationService } from "../../notification.service"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; +import { MemberModel } from "../../../../model/member.model"; +import { ErrorDataModel } from "../../../../model/errorData.model"; +import { EventStatusEnum } from "../../../../model/enums/eventStatus.enum"; +import { + isEventDataErrorModel, + isEventDataMemberModel, +} from "../eventDataValidators"; +import { GroupsService } from "../../../../groups/services/groups.service"; + +@Injectable({ + providedIn: "root", +}) +export class LeaveGroupHandler implements EventHandler { + constructor( + private readonly groupService: GroupsService, + private readonly userService: UserService, + private readonly notificationService: NotificationService, + ) {} + + handlePrivateEvent(event: PrivateEventModel): void { + switch (event.eventStatus) { + case EventStatusEnum.SUCCESSFUL: + this.visitPrivateEventSuccess(event); + break; + case EventStatusEnum.FAILED: + this.visitPrivateEventFailure(event); + break; + default: + console.warn( + "Invalid event status for private leave group event: ", + event, + ); + break; + } + } + + visitPrivateEventSuccess(event: PrivateEventModel): void { + if (!isEventDataMemberModel(event.eventData)) { + throw new Error( + "Invalid event data for private leave group successful event", + ); + } + + const exitedMember: MemberModel = event.eventData as MemberModel; + + if (this.userService.currentGroupId !== event.aggregateId) return; + + this.userService.removeUserFromGroup(); + this.notificationService.showMessage( + `Successfully left group as ${exitedMember.username}!`, + ); + } + + visitPrivateEventFailure(event: PrivateEventModel): void { + if (!isEventDataErrorModel(event.eventData)) { + throw new Error( + "Invalid event data for private leave group failure event", + ); + } + + const errorData: ErrorDataModel = event.eventData as ErrorDataModel; + this.notificationService.showMessage( + `Error leaving group: ${errorData.error}`, + ); + } + + handlePublicEvent(event: PublicEventModel): void { + switch (event.eventStatus) { + case EventStatusEnum.SUCCESSFUL: + this.visitPublicEventSuccess(event); + break; + case EventStatusEnum.FAILED: + console.debug( + "Public leave group failed event received. No action taken.", + ); + break; + default: + console.warn( + "Invalid event status for public leave group event: ", + event, + ); + break; + } + } + + visitPublicEventSuccess(event: PublicEventModel): void { + if (!isEventDataMemberModel(event.eventData)) { + throw new Error( + "Invalid event data for public leave group successful event", + ); + } + + const exitedMember: MemberModel = event.eventData as MemberModel; + + this.groupService.removeMember(exitedMember.id, event.aggregateId); + + this.showMemberLeftMessageIfInGroupAndNotSelf( + event.aggregateId, + exitedMember.id, + exitedMember.username, + ); + } + + private showMemberLeftMessageIfInGroupAndNotSelf( + groupId: number, + memberId: number, + memberName: string, + ) { + if ( + this.userService.currentGroupId === groupId && + this.userService.currentMemberId !== memberId + ) { + this.notificationService.showMessage(`${memberName} left the group`); + } + } +} diff --git a/src/app/services/notifications/notification.service.spec.ts b/src/app/services/notifications/notification.service.spec.ts new file mode 100644 index 0000000..5bbf915 --- /dev/null +++ b/src/app/services/notifications/notification.service.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from "@angular/core/testing"; +import { MatSnackBar } from "@angular/material/snack-bar"; +import { NotificationService } from "./notification.service"; + +describe("NotificationService", () => { + let service: NotificationService; + let snackBarSpy: jasmine.SpyObj; + + beforeEach(() => { + snackBarSpy = jasmine.createSpyObj("MatSnackBar", ["open"]); + + TestBed.configureTestingModule({ + providers: [ + NotificationService, + { provide: MatSnackBar, useValue: snackBarSpy }, + ], + }); + + service = TestBed.inject(NotificationService); + }); + + describe("#showMessage", () => { + it("should show message using MatSnackBar", () => { + const testMessage = "Test message"; + + service.showMessage(testMessage); + + expect(snackBarSpy.open).toHaveBeenCalledWith(testMessage, "Dismiss", { + verticalPosition: "top", + duration: 5000, + }); + }); + }); +}); diff --git a/src/app/services/notifications/notification.service.ts b/src/app/services/notifications/notification.service.ts new file mode 100644 index 0000000..4cb4413 --- /dev/null +++ b/src/app/services/notifications/notification.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from "@angular/core"; +import { MatSnackBar } from "@angular/material/snack-bar"; + +@Injectable({ + providedIn: "root", +}) +export class NotificationService { + constructor(private readonly _snackbar: MatSnackBar) {} + + public showMessage(message: string) { + console.debug("Sending message: ", message); + this._snackbar.open(message, "Dismiss", { + verticalPosition: "top", + duration: 5000, + }); + } +} diff --git a/src/app/services/notifications/visitors/eventVisitor.ts b/src/app/services/notifications/visitors/eventVisitor.ts new file mode 100644 index 0000000..d806ee5 --- /dev/null +++ b/src/app/services/notifications/visitors/eventVisitor.ts @@ -0,0 +1,7 @@ +import { PublicEventModel } from "../../../model/events/publicEvent.model"; +import { PrivateEventModel } from "../../../model/events/privateEvent.model"; + +export interface EventVisitor { + visitPrivateEvent(event: PrivateEventModel): void; + visitPublicEvent(event: PublicEventModel): void; +} diff --git a/src/app/services/notifications/visitors/group/groupEvent.visitor.spec.ts b/src/app/services/notifications/visitors/group/groupEvent.visitor.spec.ts new file mode 100644 index 0000000..6616c83 --- /dev/null +++ b/src/app/services/notifications/visitors/group/groupEvent.visitor.spec.ts @@ -0,0 +1,94 @@ +import { GroupEventVisitor } from "./groupEvent.visitor"; +import { GroupUpdatedHandler } from "../../eventhandlers/groupEventHandlers/groupUpdated.handler"; +import { JoinGroupHandler } from "../../eventhandlers/groupEventHandlers/joinGroup.handler"; +import { LeaveGroupHandler } from "../../eventhandlers/groupEventHandlers/leaveGroup.handler"; +import { TestBed } from "@angular/core/testing"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; +import { EventTypeEnum } from "../../../../model/enums/eventType.enum"; + +describe("GroupEventVisitor", () => { + let visitor: GroupEventVisitor; + let groupUpdatedHandler: GroupUpdatedHandler; + let joinGroupHandler: JoinGroupHandler; + let leaveGroupHandler: LeaveGroupHandler; + let privateEvent: PrivateEventModel; + let publicEvent: PublicEventModel; + + beforeEach(() => { + privateEvent = {} as PrivateEventModel; + publicEvent = {} as PublicEventModel; + + TestBed.configureTestingModule({ + providers: [ + GroupEventVisitor, + GroupUpdatedHandler, + JoinGroupHandler, + LeaveGroupHandler, + ], + }); + + visitor = TestBed.inject(GroupEventVisitor); + groupUpdatedHandler = TestBed.inject(GroupUpdatedHandler); + joinGroupHandler = TestBed.inject(JoinGroupHandler); + leaveGroupHandler = TestBed.inject(LeaveGroupHandler); + }); + + describe("visitPrivateEvent", () => { + it("should delegate to GroupUpdatedHandler for GROUP_UPDATED events", () => { + spyOn(groupUpdatedHandler, "handlePrivateEvent"); + privateEvent.eventType = EventTypeEnum.GROUP_UPDATED; + + visitor.visitPrivateEvent(privateEvent); + + expect(groupUpdatedHandler.handlePrivateEvent).toHaveBeenCalled(); + }); + + it("should delegate to JoinGroupHandler for MEMBER_JOINED events", () => { + spyOn(joinGroupHandler, "handlePrivateEvent"); + privateEvent.eventType = EventTypeEnum.MEMBER_JOINED; + + visitor.visitPrivateEvent(privateEvent); + + expect(joinGroupHandler.handlePrivateEvent).toHaveBeenCalled(); + }); + + it("should delegate to LeaveGroupHandler for MEMBER_LEFT events", () => { + spyOn(leaveGroupHandler, "handlePrivateEvent"); + privateEvent.eventType = EventTypeEnum.MEMBER_LEFT; + + visitor.visitPrivateEvent(privateEvent); + + expect(leaveGroupHandler.handlePrivateEvent).toHaveBeenCalled(); + }); + }); + + describe("visitPublicEvent", () => { + it("should delegate to GroupUpdatedHandler for GROUP_UPDATED events", () => { + spyOn(groupUpdatedHandler, "handlePublicEvent"); + publicEvent.eventType = EventTypeEnum.GROUP_UPDATED; + + visitor.visitPublicEvent(publicEvent); + + expect(groupUpdatedHandler.handlePublicEvent).toHaveBeenCalled(); + }); + + it("should delegate to JoinGroupHandler for MEMBER_JOINED events", () => { + spyOn(joinGroupHandler, "handlePublicEvent"); + publicEvent.eventType = EventTypeEnum.MEMBER_JOINED; + + visitor.visitPublicEvent(publicEvent); + + expect(joinGroupHandler.handlePublicEvent).toHaveBeenCalled(); + }); + + it("should delegate to LeaveGroupHandler for MEMBER_LEFT events", () => { + spyOn(leaveGroupHandler, "handlePublicEvent"); + publicEvent.eventType = EventTypeEnum.MEMBER_LEFT; + + visitor.visitPublicEvent(publicEvent); + + expect(leaveGroupHandler.handlePublicEvent).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/services/notifications/visitors/group/groupEvent.visitor.ts b/src/app/services/notifications/visitors/group/groupEvent.visitor.ts new file mode 100644 index 0000000..59feef0 --- /dev/null +++ b/src/app/services/notifications/visitors/group/groupEvent.visitor.ts @@ -0,0 +1,58 @@ +import { EventVisitor } from "../eventVisitor"; +import { PublicEventModel } from "../../../../model/events/publicEvent.model"; +import { Injectable } from "@angular/core"; +import { GroupUpdatedHandler } from "../../eventhandlers/groupEventHandlers/groupUpdated.handler"; +import { JoinGroupHandler } from "../../eventhandlers/groupEventHandlers/joinGroup.handler"; +import { LeaveGroupHandler } from "../../eventhandlers/groupEventHandlers/leaveGroup.handler"; +import { EventTypeEnum } from "../../../../model/enums/eventType.enum"; +import { PrivateEventModel } from "../../../../model/events/privateEvent.model"; + +@Injectable({ + providedIn: "root", +}) +export class GroupEventVisitor implements EventVisitor { + constructor( + private readonly groupUpdatedHandler: GroupUpdatedHandler, + private readonly joinGroupHandler: JoinGroupHandler, + private readonly leaveGroupHandler: LeaveGroupHandler, + ) {} + + visitPrivateEvent(event: PrivateEventModel): void { + console.log("PrivateGroupVisitor visited", event); + + switch (event.eventType) { + case EventTypeEnum.GROUP_UPDATED: + this.groupUpdatedHandler.handlePrivateEvent(event); + break; + case EventTypeEnum.MEMBER_JOINED: + this.joinGroupHandler.handlePrivateEvent(event); + break; + case EventTypeEnum.MEMBER_LEFT: + this.leaveGroupHandler.handlePrivateEvent(event); + break; + default: + console.warn("Unsupported event type for private group visitor", event); + break; + } + } + + visitPublicEvent(event: PublicEventModel): void { + console.log("PublicGroupVisitor visited", event); + + switch (event.eventType) { + case EventTypeEnum.GROUP_CREATED: + case EventTypeEnum.GROUP_UPDATED: + this.groupUpdatedHandler.handlePublicEvent(event); + break; + case EventTypeEnum.MEMBER_JOINED: + this.joinGroupHandler.handlePublicEvent(event); + break; + case EventTypeEnum.MEMBER_LEFT: + this.leaveGroupHandler.handlePublicEvent(event); + break; + default: + console.warn("Unsupported event type for public group visitor", event); + break; + } + } +} diff --git a/src/app/services/retry/abstractRetry.service.ts b/src/app/services/retry/abstractRetry.service.ts deleted file mode 100644 index cfd6b18..0000000 --- a/src/app/services/retry/abstractRetry.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BehaviorSubject, Observable } from "rxjs"; - -export interface RetryServiceOptions { - MAX_RETRY_ATTEMPTS?: number; - MIN_RETRY_INTERVAL?: number; - MAX_RETRY_INTERVAL?: number; -} - -export abstract class AbstractRetryService { - protected abstract readonly MAX_RETRY_ATTEMPTS?: number; - protected abstract readonly MIN_RETRY_INTERVAL?: number; - protected abstract readonly MAX_RETRY_INTERVAL?: number; - protected abstract readonly _nextRetryInSeconds$: BehaviorSubject< - number | null - >; - - abstract get nextRetryInSeconds$(): BehaviorSubject; - - abstract addRetryLogic(observable: Observable): Observable; - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - protected abstract getNotifier(error: any, retryCount: number): any; -} diff --git a/src/app/services/retry/retry.data.ts b/src/app/services/retry/retry.data.ts new file mode 100644 index 0000000..1c0f51b --- /dev/null +++ b/src/app/services/retry/retry.data.ts @@ -0,0 +1,8 @@ +import { BehaviorSubject, Subject, Subscription } from "rxjs"; + +export interface RetryData { + nextRetryInSeconds$: BehaviorSubject; + nextRetrySubscription: Subscription | null; + version: string; + stopSignal$: Subject; +} diff --git a/src/app/services/retry/retry.options.ts b/src/app/services/retry/retry.options.ts new file mode 100644 index 0000000..ac1e32e --- /dev/null +++ b/src/app/services/retry/retry.options.ts @@ -0,0 +1,5 @@ +export interface RetryOptions { + MAX_ATTEMPTS: number; + MIN_RETRY_INTERVAL: number; + MAX_RETRY_INTERVAL: number; +} diff --git a/src/app/services/retry/retry.service.spec.ts b/src/app/services/retry/retry.service.spec.ts new file mode 100644 index 0000000..e805d2d --- /dev/null +++ b/src/app/services/retry/retry.service.spec.ts @@ -0,0 +1,379 @@ +import { RetryService } from "./retry.service"; +import { TestBed } from "@angular/core/testing"; +import { RetryStrategy } from "./strategies/retry.strategy"; +import { defer, Observable, of, throwError } from "rxjs"; +import { v4 as uuidv4 } from "uuid"; +import { TestScheduler } from "rxjs/internal/testing/TestScheduler"; + +function createRetryStrategy( + maxAttempts: number, + minInterval: number, + maxInterval: number, +): RetryStrategy { + return { + retryServiceOptions: { + MAX_ATTEMPTS: maxAttempts, + MIN_RETRY_INTERVAL: minInterval, + MAX_RETRY_INTERVAL: maxInterval, + }, + }; +} + +function generateRetryMarbles( + retryStrategy: RetryStrategy, + success = false, +): string { + const retryOptions = retryStrategy.retryServiceOptions; + + const attempts = Array.from( + { length: retryOptions.MAX_ATTEMPTS - 1 }, + () => `${retryOptions.MIN_RETRY_INTERVAL}s `, + ).join(""); + + return success ? attempts + "(a|)" : attempts + "#"; +} + +function createFailingObservable( + retryStrategy: RetryStrategy, +): Observable { + const attemptsBeforeSuccess = + retryStrategy.retryServiceOptions.MAX_ATTEMPTS - 1; + + if (attemptsBeforeSuccess < 1) { + throw new Error(`Max retry options too low: ${retryStrategy.retryServiceOptions.MAX_ATTEMPTS}. + Must be at least 2 in order to fail then succeed`); + } + + let attempts = 0; + + return defer(() => { + if (attempts++ < attemptsBeforeSuccess) { + return throwError(() => new Error("Simulated error")); + } else { + return of("success"); + } + }); +} + +describe("RetryService", () => { + let service: RetryService; + + let retryStrategyA: RetryStrategy; + let observableKeyA: string; + + let retryStrategyB: RetryStrategy; + let observableKeyB: string; + + let testScheduler: TestScheduler; + + beforeEach(() => { + retryStrategyA = createRetryStrategy(5, 5, 5); + observableKeyA = uuidv4(); + + retryStrategyB = createRetryStrategy(7, 10, 10); + observableKeyB = uuidv4(); + + observableKeyB = uuidv4(); + + TestBed.configureTestingModule({ + providers: [RetryService], + }); + + service = TestBed.inject(RetryService); + expect(service).toBeTruthy(); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + describe("retry strategy behavior", () => { + it("should retry an observable up to the maximum number of attempts", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const MAX_RETRIES = retryStrategyA.retryServiceOptions.MAX_ATTEMPTS; + expect(MAX_RETRIES).toBeGreaterThanOrEqual(2); + + const retryMarbles = generateRetryMarbles(retryStrategyA); + + const errorObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategyA, + ); + + expectObservable(errorObservableWithRetry).toBe(retryMarbles); + }); + }); + + it("should stop retrying once successful", () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + + const observableWithRetry = service.addRetryLogic( + createFailingObservable(retryStrategyA), + observableKeyA, + retryStrategyA, + ); + + const expectedMarbles = generateRetryMarbles(retryStrategyA, true); + expectObservable(observableWithRetry).toBe(expectedMarbles, { + a: "success", + }); + }); + }); + + it("should not allow subscriptions to an outdated observable", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const errorObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategyA, + ); + + const newErrorObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategyA, + ); + + expectObservable(errorObservableWithRetry).toBe( + "#", + undefined, + new Error("Observable version mismatch"), + ); + expectObservable(newErrorObservableWithRetry).toBe( + generateRetryMarbles(retryStrategyA), + ); + }); + }); + + it("should complete an existing observable when a new one is created with the same key", () => { + testScheduler.run((helpers) => { + const { cold, hot, expectObservable } = helpers; + + const firstObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategyA, + ); + + const completionSignal = hot(" -----x"); + completionSignal.subscribe(() => { + service.addRetryLogic(cold("-"), observableKeyA, retryStrategyA); + }); + + expectObservable(firstObservableWithRetry).toBe("-----|"); + }); + }); + + it("should not complete an existing observable when a new one is created with a different key", () => { + testScheduler.run((helpers) => { + const { cold, hot, expectObservable } = helpers; + + const firstObservableWithRetry = service.addRetryLogic( + cold("-"), + observableKeyA, + retryStrategyA, + ); + + const completionSignal = hot(" -----x"); + completionSignal.subscribe(() => { + service.addRetryLogic(cold("-"), observableKeyB, retryStrategyA); + }); + + expectObservable(firstObservableWithRetry).toBe("-"); + }); + }); + + it("should complete an existing observable's retry time when a new one is created with the same key", () => { + testScheduler.run((helpers) => { + const { cold, hot, expectObservable } = helpers; + + const firstObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategyA, + ); + + const nextRetryTimeObservable = + service.getNextRetryTime$(observableKeyA); + expect(nextRetryTimeObservable).toBeTruthy(); + + const completionSignal = hot("-----x"); + completionSignal.subscribe(() => { + service.addRetryLogic(cold("-"), observableKeyA, retryStrategyA); + }); + + expectObservable(firstObservableWithRetry).toBe("-----|"); + expectObservable(nextRetryTimeObservable!).toBe("(ab)-|", { + a: 0, + b: 5, + }); + }); + }); + + it("should not complete an existing observable's retry time when a new one is created with a different key", () => { + testScheduler.run((helpers) => { + const { cold, hot, expectObservable } = helpers; + + const firstObservableWithRetry = service.addRetryLogic( + cold("-"), + observableKeyA, + retryStrategyA, + ); + + const nextRetryTimeObservable = + service.getNextRetryTime$(observableKeyA); + expect(nextRetryTimeObservable).toBeTruthy(); + + const completionSignal = hot("-----x"); + completionSignal.subscribe(() => { + service.addRetryLogic(cold("-"), observableKeyB, retryStrategyA); + }); + + expectObservable(firstObservableWithRetry).toBe("-"); + expectObservable(nextRetryTimeObservable!).toBe("a", { a: 0 }); + }); + }); + + describe("retry interval", () => { + it("should save the next retry time after each attempt", () => { + testScheduler.run((helpers) => { + service.currentTime = () => testScheduler.now(); + const { cold, expectObservable } = helpers; + + const retryStrategy = createRetryStrategy(2, 5, 5); + + const errorObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategy, + ); + + const nextRetryTimeObservable = + service.getNextRetryTime$(observableKeyA); + expect(nextRetryTimeObservable).toBeTruthy(); + + expectObservable(errorObservableWithRetry).toBe( + generateRetryMarbles(retryStrategy), + ); + expectObservable(nextRetryTimeObservable!).toBe( + "(ab) 996ms c 999ms d 999ms e 999ms f 999ms (g|)", + { + a: 0, + b: 5, + c: 4, + d: 3, + e: 2, + f: 1, + g: 0, + }, + ); + }); + }); + + it("should not exceed the maximum retry interval", () => { + testScheduler.run((helpers) => { + service.currentTime = () => testScheduler.now(); + const { cold, expectObservable } = helpers; + + const retryStrategy = createRetryStrategy(2, 1, 1); + + const errorObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategy, + ); + + const nextRetryTimeObservable = + service.getNextRetryTime$(observableKeyA); + expect(nextRetryTimeObservable).toBeTruthy(); + + expectObservable(errorObservableWithRetry).toBe( + generateRetryMarbles(retryStrategy), + ); + expectObservable(nextRetryTimeObservable!).toBe("(ab) 996ms (c|)", { + a: 0, + b: 1, + c: 0, + }); + }); + }); + }); + }); + + describe("multiple concurrent retry strategies", () => { + it("should use the correct retry strategy for each observable", () => { + testScheduler.run((helpers) => { + const { cold, expectObservable } = helpers; + + const errorObservableWithStrategyARetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategyA, + ); + + const errorObservableWithStrategyBRetry = service.addRetryLogic( + cold("#"), + observableKeyB, + retryStrategyB, + ); + + expectObservable(errorObservableWithStrategyARetry).toBe( + generateRetryMarbles(retryStrategyA), + ); + expectObservable(errorObservableWithStrategyBRetry).toBe( + generateRetryMarbles(retryStrategyB), + ); + }); + }); + }); + + describe("backoff time calculation", () => { + it("should increase the retry time for every retry", () => { + testScheduler.run((helpers) => { + service.currentTime = () => testScheduler.now(); + const { cold, expectObservable } = helpers; + + const retryStrategy = createRetryStrategy(5, 1, 60); + + const errorObservableWithRetry = service.addRetryLogic( + cold("#"), + observableKeyA, + retryStrategy, + ); + + const nextRetryTimeObservable = + service.getNextRetryTime$(observableKeyA); + expect(nextRetryTimeObservable).toBeTruthy(); + + expectObservable(errorObservableWithRetry); + + let retryFinished = false; + let maxRetryTime: number | null = null; + + nextRetryTimeObservable!.subscribe((retryTime) => { + if (maxRetryTime === null) { + maxRetryTime = retryTime; + return; + } + + if (retryTime == 0) { + retryFinished = true; + return; + } + + if (retryFinished) { + expect(retryTime).toBeGreaterThan(maxRetryTime); + maxRetryTime = retryTime; + retryFinished = false; + } + }); + }); + }); + }); +}); diff --git a/src/app/services/retry/retry.service.ts b/src/app/services/retry/retry.service.ts new file mode 100644 index 0000000..5a4b3c5 --- /dev/null +++ b/src/app/services/retry/retry.service.ts @@ -0,0 +1,176 @@ +import { RetryStrategy } from "./strategies/retry.strategy"; +import { RetryOptions } from "./retry.options"; +import { + BehaviorSubject, + defer, + finalize, + interval, + Observable, + ObservableInput, + retry, + Subject, + takeUntil, + throwError, + timer, +} from "rxjs"; +import { RetryData } from "./retry.data"; +import { Injectable } from "@angular/core"; +import { v4 as uuidv4 } from "uuid"; + +@Injectable({ + providedIn: "root", +}) +export class RetryService { + private readonly retryDataMap = new Map(); + private _currentTime: () => number = () => Date.now(); + + set currentTime(provider: () => number) { + this._currentTime = provider; + } + + get currentTime() { + return this._currentTime; + } + + public getNextRetryTime$( + observableKey: string, + ): Observable | undefined { + if (!this.retryDataMap.has(observableKey)) { + return undefined; + } + + const retryData: RetryData = this.retryDataMap.get(observableKey)!; + + return retryData.nextRetryInSeconds$ + .asObservable() + .pipe(takeUntil(retryData.stopSignal$)); + } + + public addRetryLogic( + observable: Observable, + observableKey: string, + retryStrategy: RetryStrategy, + ): Observable { + const retryServiceOptions: RetryOptions = retryStrategy.retryServiceOptions; + + if (this.retryDataMap.has(observableKey)) { + this.cleanupRetryData(observableKey); + } + + const retryData: RetryData = { + nextRetryInSeconds$: new BehaviorSubject(0), + nextRetrySubscription: null, + version: uuidv4(), + stopSignal$: new Subject(), + }; + + this.retryDataMap.set(observableKey, retryData); + + const delayWithKey = (error: any, retryCount: number) => + this.getNotifier(error, retryCount, observableKey, retryServiceOptions); + + const retryObservable = observable.pipe( + retry({ + count: retryServiceOptions.MAX_ATTEMPTS, + delay: delayWithKey, + }), + takeUntil(retryData.stopSignal$), + finalize(() => { + console.log("Retry observable completed"); + this.cleanupRetryData(observableKey); + }), + ); + + return defer(() => { + if (this.observableValid(observableKey, retryData.version)) { + return retryObservable; + } else if (this.retryDataMap.has(observableKey)) { + return throwError(() => new Error("Observable version mismatch")); + } else { + return throwError(() => new Error("Observable not found")); + } + }); + } + private observableValid(observableKey: string, version: string): boolean { + const retryData = this.retryDataMap.get(observableKey); + + if (!retryData) { + return false; + } + + return retryData.version === version; + } + + private cleanupRetryData(observableKey: string) { + const retryData = this.retryDataMap.get(observableKey); + if (retryData) { + retryData.nextRetryInSeconds$.complete(); + retryData.nextRetrySubscription?.unsubscribe(); + retryData.stopSignal$.next(); // unsubscribe subscribers to currently wrapped observable linked to observableKey + this.retryDataMap.delete(observableKey); + } + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + private getNotifier( + error: any, + retryCount: number, + observableKey: string, + retryServiceOptions: RetryOptions, + ): ObservableInput { + console.log("Retrying..."); + + const retryData = this.retryDataMap.get(observableKey); + + if (!retryData) { + return throwError(() => new Error("Retry data not found!")); + } + + if (retryCount >= retryServiceOptions.MAX_ATTEMPTS) { + console.debug("Retries exhausted"); + return throwError(() => error); + } + + const delay = this.exponentialBackoff( + retryCount, + retryServiceOptions.MAX_RETRY_INTERVAL, + retryServiceOptions.MIN_RETRY_INTERVAL, + ); + + retryData.nextRetryInSeconds$.next(this.retryInSeconds(delay)); + this.updateNextRetry(delay, retryData); + return timer(delay); + } + + /* eslint-enable */ + private exponentialBackoff( + attempt: number, + cap: number, + minimum: number, + base = 2, + ) { + const jitter = Math.random(); + const backoff = Math.max(base ** attempt + jitter, minimum); + const backoffCapped = Math.min(backoff, cap); + + console.debug(`Backoff: ${backoffCapped} seconds`); + return backoffCapped * 1000; + } + + private updateNextRetry(delay: number, retryData: RetryData) { + const nextRetryDate = new Date(this.currentTime() + delay); + retryData.nextRetrySubscription?.unsubscribe(); + + retryData.nextRetrySubscription = interval(1000).subscribe(() => { + const difference = nextRetryDate.getTime() - this.currentTime(); + const seconds = Math.floor(difference / 1000); + retryData.nextRetryInSeconds$.next(seconds > 0 ? seconds : 0); + }); + } + + protected retryInSeconds(delay: number) { + const retryDate = this.currentTime() + delay; + const timeUntilRetry = retryDate - this.currentTime(); + return Math.floor(timeUntilRetry / 1000); + } +} diff --git a/src/app/services/retry/retryDefault.service.spec.ts b/src/app/services/retry/retryDefault.service.spec.ts deleted file mode 100644 index 6480088..0000000 --- a/src/app/services/retry/retryDefault.service.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { of, throwError } from "rxjs"; -import { RetryDefaultService } from "./retryDefault.service"; -import { TestBed } from "@angular/core/testing"; -import { ConfigService } from "../../config/config.service"; - -describe("RetryDefaultService", () => { - let service: RetryDefaultService; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - RetryDefaultService, - { provide: ConfigService, useValue: null }, - ], - }); - service = TestBed.inject(RetryDefaultService); - jasmine.clock().install(); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); - - it("should return a retry logic", () => { - const observable = of("test"); - const retryLogic = service.addRetryLogic(observable); - expect(retryLogic).toBeTruthy(); - }); - - it("should retry an observable emitting an error", (done) => { - const observable = throwError(() => new Error("test")); - const retryLogic = service.addRetryLogic(observable); - const subscription = retryLogic.subscribe({ - error: (err) => { - expect(err).toBeTruthy(); - subscription.unsubscribe(); - done(); - }, - }); - - for (let i = 0; i < service.MAX_RETRY_ATTEMPTS; i++) { - jasmine.clock().tick(service.MIN_RETRY_INTERVAL * 1000); - expect(subscription.closed).toBe(false); - } - - for (let i = 0; i < service.MAX_RETRY_ATTEMPTS; i++) { - jasmine.clock().tick(service.MAX_RETRY_INTERVAL * 1000); - } - - expect(subscription.closed).toBe(true); - }); - - afterEach(() => { - jasmine.clock().uninstall(); - }); -}); diff --git a/src/app/services/retry/retryDefault.service.ts b/src/app/services/retry/retryDefault.service.ts deleted file mode 100644 index 5ca6d15..0000000 --- a/src/app/services/retry/retryDefault.service.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { BehaviorSubject, Observable, ObservableInput, retry } from "rxjs"; -import { AbstractRetryService } from "./abstractRetry.service"; -import { Injectable } from "@angular/core"; -import { ConfigService } from "../../config/config.service"; - -@Injectable() -export class RetryDefaultService extends AbstractRetryService { - public readonly MAX_RETRY_ATTEMPTS: number = 7; - public readonly MIN_RETRY_INTERVAL: number = 5; - public readonly MAX_RETRY_INTERVAL: number = 60; - protected readonly _nextRetryInSeconds$ = new BehaviorSubject( - null, - ); - private nextRetryIntervalId: any; // eslint-disable-line @typescript-eslint/no-explicit-any - - constructor(readonly configService?: ConfigService) { - super(); - console.debug(configService); - const retryConfig = configService?.retryServices.retryDefault; - - if (!retryConfig) { - return; - } - - this.MAX_RETRY_ATTEMPTS = - retryConfig.MAX_RETRY_ATTEMPTS ?? this.MAX_RETRY_ATTEMPTS; - this.MIN_RETRY_INTERVAL = - retryConfig.MIN_RETRY_INTERVAL ?? this.MIN_RETRY_INTERVAL; - this.MAX_RETRY_INTERVAL = - retryConfig.MAX_RETRY_INTERVAL ?? this.MAX_RETRY_INTERVAL; - } - - public get nextRetryInSeconds$() { - return this._nextRetryInSeconds$; - } - - public addRetryLogic(observable: Observable) { - clearInterval(this.nextRetryIntervalId); - return observable.pipe( - retry({ - count: this.MAX_RETRY_ATTEMPTS, - delay: this.getNotifier.bind(this), - }), - ); - } - - private exponentialBackoff( - attempt: number, - base = 2, - cap: number = this.MAX_RETRY_INTERVAL, - minimum: number = this.MIN_RETRY_INTERVAL, - ) { - const jitter = Math.random() * 1000; - const minimumBackoff = minimum * 1000 + jitter; - const backoff = Math.min(base ** attempt, cap) * 1000 + jitter; - - console.debug(Math.min(base ** attempt, cap)); - return Math.max(backoff, minimumBackoff); - } - - /* eslint-disable @typescript-eslint/no-explicit-any */ - protected getNotifier(error: any, retryCount: number): ObservableInput { - console.debug(this); - if (retryCount < this.MAX_RETRY_ATTEMPTS) { - const delay = this.exponentialBackoff(retryCount); - - return new Observable((observer) => { - this.nextRetryInSeconds$.next(this.retryInSeconds(delay)); - this.updateRetryTimer(delay); - setTimeout(() => { - observer.next(); - observer.complete(); - }, delay); - }); - } else { - return new Observable((observer) => { - this.nextRetryInSeconds$.next(null); - observer.error(error); - }); - } - } - /* eslint-enable */ - - private updateRetryTimer(delay: number) { - const nextRetryDate = new Date(Date.now() + delay); - clearInterval(this.nextRetryIntervalId); - this.nextRetryIntervalId = setInterval(() => { - const difference = nextRetryDate.getTime() - Date.now(); - const seconds = Math.floor(difference / 1000); - this.nextRetryInSeconds$.next(seconds); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (this.nextRetryInSeconds$.getValue()! <= 0) { - clearInterval(this.nextRetryIntervalId); - } - }, 1000); - } - - protected retryInSeconds(delay: number) { - const retryDate = Date.now() + delay; - const timeUntilRetry = retryDate - Date.now(); - return Math.floor(timeUntilRetry / 1000); - } -} diff --git a/src/app/services/retry/retryForeverConstant.service.spec.ts b/src/app/services/retry/retryForeverConstant.service.spec.ts deleted file mode 100644 index afb6df4..0000000 --- a/src/app/services/retry/retryForeverConstant.service.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { of, throwError } from "rxjs"; -import { TestBed } from "@angular/core/testing"; -import { ConfigService } from "../../config/config.service"; -import { RetryForeverConstantService } from "./retryForeverConstant.service"; - -describe("RetryForeverConstantService", () => { - let service: RetryForeverConstantService; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - RetryForeverConstantService, - { provide: ConfigService, useValue: null }, - ], - }); - service = TestBed.inject(RetryForeverConstantService); - jasmine.clock().install(); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); - - it("should return a retry logic", () => { - const observable = of("test"); - const retryLogic = service.addRetryLogic(observable); - expect(retryLogic).toBeTruthy(); - }); - - it("should retry an observable emitting an error", (done) => { - const observable = throwError(() => new Error("test")); - const retryLogic = service.addRetryLogic(observable); - const subscription = retryLogic.subscribe({ - error: (err) => { - fail(`Should not have errored: ${err}`); - subscription.unsubscribe(); - }, - }); - - // ticks forward 1 day. Caution: longer times will take longer to tick through - jasmine.clock().tick(1000 * 60 * 60 * 24); - expect(subscription.closed).toBe(false); - done(); - }); - - afterEach(() => { - jasmine.clock().uninstall(); - }); -}); diff --git a/src/app/services/retry/retryForeverConstant.service.ts b/src/app/services/retry/retryForeverConstant.service.ts deleted file mode 100644 index 4247c8a..0000000 --- a/src/app/services/retry/retryForeverConstant.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { BehaviorSubject, Observable, ObservableInput, retry } from "rxjs"; -import { AbstractRetryService } from "./abstractRetry.service"; -import { Injectable } from "@angular/core"; -import { ConfigService } from "../../config/config.service"; - -@Injectable() -export class RetryForeverConstantService extends AbstractRetryService { - public readonly MAX_RETRY_ATTEMPTS: number = Number.MAX_VALUE; - public readonly MIN_RETRY_INTERVAL: number = 5; - public readonly MAX_RETRY_INTERVAL: number = 5; - protected readonly _nextRetryInSeconds$ = new BehaviorSubject( - null, - ); - - constructor(readonly configService?: ConfigService) { - super(); - console.debug(configService); - const retryConfig = configService?.retryServices.retryForeverConstant; - - if (!retryConfig) { - return; - } - - this.MAX_RETRY_ATTEMPTS = - retryConfig.MAX_RETRY_ATTEMPTS ?? this.MAX_RETRY_ATTEMPTS; - this.MIN_RETRY_INTERVAL = - retryConfig.MIN_RETRY_INTERVAL ?? this.MIN_RETRY_INTERVAL; - this.MAX_RETRY_INTERVAL = - retryConfig.MAX_RETRY_INTERVAL ?? this.MAX_RETRY_INTERVAL; - } - - public get nextRetryInSeconds$() { - return this._nextRetryInSeconds$; - } - - public addRetryLogic(observable: Observable) { - return observable.pipe( - retry({ - count: Number.MAX_VALUE, - delay: this.getNotifier.bind(this), - }), - ); - } - - private jitter() { - return Math.random() * 1000; - } - - /* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */ - protected getNotifier(_: any, __: number): ObservableInput { - const delay = this.MIN_RETRY_INTERVAL * 1000 + this.jitter(); - - return new Observable((observer) => { - this.nextRetryInSeconds$.next(Math.floor(delay / 1000)); - setTimeout(() => { - observer.next(); - observer.complete(); - }, delay); - }); - } - /* eslint-enable */ -} diff --git a/src/app/services/retry/retryable.ts b/src/app/services/retry/retryable.ts new file mode 100644 index 0000000..6914ed2 --- /dev/null +++ b/src/app/services/retry/retryable.ts @@ -0,0 +1,6 @@ +import { Observable } from "rxjs"; + +export interface Retryable { + get nextRetryTime$(): Observable | undefined; + retryNow(): void; +} diff --git a/src/app/services/retry/strategies/retry.strategy.ts b/src/app/services/retry/strategies/retry.strategy.ts new file mode 100644 index 0000000..cad06b3 --- /dev/null +++ b/src/app/services/retry/strategies/retry.strategy.ts @@ -0,0 +1,5 @@ +import { RetryOptions } from "../retry.options"; + +export interface RetryStrategy { + get retryServiceOptions(): RetryOptions; +} diff --git a/src/app/services/retry/strategies/retryDefault.strategy.ts b/src/app/services/retry/strategies/retryDefault.strategy.ts new file mode 100644 index 0000000..b19cce7 --- /dev/null +++ b/src/app/services/retry/strategies/retryDefault.strategy.ts @@ -0,0 +1,23 @@ +import { RetryOptions } from "../retry.options"; +import { RetryStrategy } from "./retry.strategy"; +import { ConfigService } from "../../../config/config.service"; + +export class RetryDefaultStrategy implements RetryStrategy { + protected _retryServiceOptions: RetryOptions = { + MAX_ATTEMPTS: 5, + MIN_RETRY_INTERVAL: 5, + MAX_RETRY_INTERVAL: 5, + }; + + constructor(readonly configService: ConfigService) { + console.debug(configService); + + if (configService?.retryDefaultStrategy) { + this._retryServiceOptions = configService?.retryDefaultStrategy; + } + } + + public get retryServiceOptions(): RetryOptions { + return this._retryServiceOptions; + } +} diff --git a/src/app/services/retry/strategies/retryForever.strategy.ts b/src/app/services/retry/strategies/retryForever.strategy.ts new file mode 100644 index 0000000..773b4a8 --- /dev/null +++ b/src/app/services/retry/strategies/retryForever.strategy.ts @@ -0,0 +1,23 @@ +import { RetryStrategy } from "./retry.strategy"; +import { ConfigService } from "../../../config/config.service"; +import { RetryOptions } from "../retry.options"; + +export class RetryForeverStrategy implements RetryStrategy { + protected _retryServiceOptions: RetryOptions = { + MAX_ATTEMPTS: Number.MAX_VALUE, + MIN_RETRY_INTERVAL: 5, + MAX_RETRY_INTERVAL: 5, + }; + + constructor(readonly configService: ConfigService) { + console.debug(configService); + + if (configService?.retryForeverStrategy) { + this._retryServiceOptions = configService?.retryForeverStrategy; + } + } + + public get retryServiceOptions(): RetryOptions { + return this._retryServiceOptions; + } +} diff --git a/src/app/services/state/StateEnum.ts b/src/app/services/state/StateEnum.ts new file mode 100644 index 0000000..4ce583b --- /dev/null +++ b/src/app/services/state/StateEnum.ts @@ -0,0 +1,31 @@ +export enum StateEnum { + DORMANT = "DORMANT", + INITIALIZING = "INITIALIZING", + LOADING = "LOADING", + REQUESTING = "REQUESTING", + RETRYING = "RETRYING", + READY = "READY", + REQUEST_ACCEPTED = "REQUEST_ACCEPTED", + REQUEST_COMPLETED = "REQUEST_COMPLETED", + REQUEST_REJECTED = "REQUEST_REJECTED", + REQUEST_TIMEOUT = "REQUEST_TIMEOUT", + EVENT_PROCESSED = "EVENT_PROCESSED", + EVENT_PROCESSING_TIMEOUT = "EVENT_PROCESSING_TIMEOUT", +} + +export function getAllStateEnums() { + return [ + StateEnum.DORMANT, + StateEnum.INITIALIZING, + StateEnum.LOADING, + StateEnum.REQUESTING, + StateEnum.RETRYING, + StateEnum.READY, + StateEnum.REQUEST_ACCEPTED, + StateEnum.REQUEST_COMPLETED, + StateEnum.REQUEST_REJECTED, + StateEnum.REQUEST_TIMEOUT, + StateEnum.EVENT_PROCESSED, + StateEnum.EVENT_PROCESSING_TIMEOUT, + ]; +} diff --git a/src/app/services/state/request/dormant.state.spec.ts b/src/app/services/state/request/dormant.state.spec.ts new file mode 100644 index 0000000..725dada --- /dev/null +++ b/src/app/services/state/request/dormant.state.spec.ts @@ -0,0 +1,34 @@ +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { RequestState } from "./request.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { BehaviorSubject } from "rxjs"; +import { setupStateTest } from "./stateHelperSetup.spec"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { DormantState } from "./dormant.state"; +import { StateEnum } from "../StateEnum"; + +describe("DormantState", () => { + let setup: { + state: any; + requestServiceSpy: jasmine.SpyObj>; + mockConnectorState$: BehaviorSubject; + currentStateContainer: { currentState: RequestState | null }; + }; + + beforeEach(() => { + setup = setupStateTest(DormantState, ConnectorStatesEnum.INITIALIZING); + }); + + it("transitions to the WaitingForRsocketState and sets state to INITIALIZING when a request is received", () => { + spyOn(setup.state, "cleanUp"); + setup.currentStateContainer.currentState!.onRequest(); + + expect(setup.requestServiceSpy.nextRequestState).toHaveBeenCalledWith( + StateEnum.INITIALIZING, + ); + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + WaitingForRsocketState, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/state/request/dormant.state.ts b/src/app/services/state/request/dormant.state.ts new file mode 100644 index 0000000..d895249 --- /dev/null +++ b/src/app/services/state/request/dormant.state.ts @@ -0,0 +1,15 @@ +import { RequestState } from "./request.state"; +import { Observable } from "rxjs"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { StateEnum } from "../StateEnum"; + +export class DormantState extends RequestState { + override onRequest(): Observable { + this.cleanUp(); + console.log("DormantState handles request."); + console.log("DormantState is transitioning to WaitingForRsocketState."); + this.requestService.nextRequestState(StateEnum.INITIALIZING); + this.requestService.state = new WaitingForRsocketState(this.requestService); + return this.requestService.getEvents$(); + } +} diff --git a/src/app/services/state/request/receivingData.state.spec.ts b/src/app/services/state/request/receivingData.state.spec.ts new file mode 100644 index 0000000..06a0510 --- /dev/null +++ b/src/app/services/state/request/receivingData.state.spec.ts @@ -0,0 +1,37 @@ +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { RequestState } from "./request.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { BehaviorSubject } from "rxjs"; +import { setupStateTest } from "./stateHelperSetup.spec"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { ReceivingDataState } from "./receivingData.state"; +import { StateEnum } from "../StateEnum"; + +describe("ReceivingDataState", () => { + let setup: { + state: any; + requestServiceSpy: jasmine.SpyObj>; + mockConnectorState$: BehaviorSubject; + currentStateContainer: { currentState: RequestState | null }; + }; + + beforeEach(() => { + setup = setupStateTest(ReceivingDataState, ConnectorStatesEnum.CONNECTED); + }); + + it("sets the request state to READY", () => { + expect(setup.requestServiceSpy.nextRequestState).toHaveBeenCalledWith( + StateEnum.READY, + ); + }); + + it("transitions to the WaitingForRsocketState when the connector is not CONNECTED", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.INITIALIZING); + + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + WaitingForRsocketState, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/state/request/receivingData.state.ts b/src/app/services/state/request/receivingData.state.ts new file mode 100644 index 0000000..dc7f955 --- /dev/null +++ b/src/app/services/state/request/receivingData.state.ts @@ -0,0 +1,30 @@ +import { RequestState } from "./request.state"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { StateEnum } from "../StateEnum"; + +export class ReceivingDataState extends RequestState { + constructor(requestService: RequestServiceStateInterface) { + super(requestService); + + this.requestService.nextRequestState(StateEnum.READY); + + const subscription = this.requestService.connectorState.subscribe( + (state) => { + if (state !== ConnectorStatesEnum.CONNECTED) { + this.onRsocketDisconnect(); + } + }, + ); + + this.subscriptions.add(subscription); + } + + onRsocketDisconnect() { + console.error("Rsocket disconnected while receiving data."); + this.cleanUp(); + console.log("Transitioning to WaitingForRsocketState."); + this.requestService.state = new WaitingForRsocketState(this.requestService); + } +} diff --git a/src/app/services/state/request/request.state.ts b/src/app/services/state/request/request.state.ts new file mode 100644 index 0000000..6c21207 --- /dev/null +++ b/src/app/services/state/request/request.state.ts @@ -0,0 +1,19 @@ +import { Observable, Subscription } from "rxjs"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; + +export abstract class RequestState { + protected readonly subscriptions: Subscription = new Subscription(); + + constructor( + protected readonly requestService: RequestServiceStateInterface, + ) {} + + onRequest(): Observable { + console.log("Returning existing events$."); + return this.requestService.getEvents$(); + } + + cleanUp(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/src/app/services/state/request/requestComplete.state.spec.ts b/src/app/services/state/request/requestComplete.state.spec.ts new file mode 100644 index 0000000..fcc3f61 --- /dev/null +++ b/src/app/services/state/request/requestComplete.state.spec.ts @@ -0,0 +1,68 @@ +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { BehaviorSubject } from "rxjs"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { RequestState } from "./request.state"; +import { setupStateTest } from "./stateHelperSetup.spec"; +import { StateEnum } from "../StateEnum"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { RequestingState } from "./requesting.state"; +import { RsocketRetryingState } from "./rsocketRetrying.state"; +import { RequestCompleteState } from "./requestComplete.state"; + +describe("RequestCompleteState", () => { + let setup: { + state: any; + requestServiceSpy: jasmine.SpyObj>; + mockConnectorState$: BehaviorSubject; + currentStateContainer: { currentState: RequestState | null }; + }; + + beforeEach(() => { + setup = setupStateTest(RequestCompleteState, ConnectorStatesEnum.CONNECTED); + }); + + it("transitions to the RequestingState when the connector is CONNECTED and a request is received", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.CONNECTED); + + setup.currentStateContainer.currentState!.onRequest(); + + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + RequestingState, + ); + expect(setup.requestServiceSpy.nextRequestState).toHaveBeenCalledWith( + StateEnum.LOADING, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); + + it("transitions to the WaitingForRsocketState when the connector is RETRYING and a request is received", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.RETRYING); + + setup.currentStateContainer.currentState!.onRequest(); + + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + RsocketRetryingState, + ); + expect(setup.requestServiceSpy.nextRequestState).toHaveBeenCalledWith( + StateEnum.RETRYING, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); + + it("transitions to the WaitingForRsocketState when the connector is INITIALIZING and a request is received", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.INITIALIZING); + + setup.currentStateContainer.currentState!.onRequest(); + + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + WaitingForRsocketState, + ); + expect(setup.requestServiceSpy.nextRequestState).toHaveBeenCalledWith( + StateEnum.LOADING, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/state/request/requestComplete.state.ts b/src/app/services/state/request/requestComplete.state.ts new file mode 100644 index 0000000..8c59f27 --- /dev/null +++ b/src/app/services/state/request/requestComplete.state.ts @@ -0,0 +1,59 @@ +import { RequestState } from "./request.state"; +import { Observable } from "rxjs"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { RequestingState } from "./requesting.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { RsocketRetryingState } from "./rsocketRetrying.state"; +import { StateEnum } from "../StateEnum"; + +export class RequestCompleteState extends RequestState { + private currentConnectorState: ConnectorStatesEnum = + ConnectorStatesEnum.CONNECTED; + + constructor(requestService: RequestServiceStateInterface) { + super(requestService); + + const subscription = this.requestService.connectorState.subscribe( + (state) => { + this.currentConnectorState = state; + }, + ); + + this.subscriptions.add(subscription); + } + + override onRequest(): Observable { + this.cleanUp(); + + console.log("RequestComplete handles request."); + + switch (this.currentConnectorState) { + case ConnectorStatesEnum.CONNECTED: + console.log("Rsocket connected. Transitioning to RequestingState."); + this.requestService.nextRequestState(StateEnum.LOADING); + this.requestService.state = new RequestingState(this.requestService); + break; + case ConnectorStatesEnum.RETRYING: + console.log( + "Rsocket is retrying. Transitioning to RsocketRetryingState.", + ); + this.requestService.nextRequestState(StateEnum.RETRYING); + this.requestService.state = new RsocketRetryingState( + this.requestService, + ); + break; + case ConnectorStatesEnum.INITIALIZING: + console.error( + "Rsocket is initializing. Transitioning to WaitingForRsocketState.", + ); + this.requestService.nextRequestState(StateEnum.LOADING); + this.requestService.state = new WaitingForRsocketState( + this.requestService, + ); + break; + } + + return this.requestService.getEvents$(); + } +} diff --git a/src/app/services/state/request/requesting.state.spec.ts b/src/app/services/state/request/requesting.state.spec.ts new file mode 100644 index 0000000..b62c726 --- /dev/null +++ b/src/app/services/state/request/requesting.state.spec.ts @@ -0,0 +1,34 @@ +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { RequestState } from "./request.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { BehaviorSubject } from "rxjs"; +import { setupStateTest } from "./stateHelperSetup.spec"; +import { RequestingState } from "./requesting.state"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; + +describe("RequestingState", () => { + let setup: { + state: any; + requestServiceSpy: jasmine.SpyObj>; + mockConnectorState$: BehaviorSubject; + currentStateContainer: { currentState: RequestState | null }; + }; + + beforeEach(() => { + setup = setupStateTest(RequestingState, ConnectorStatesEnum.CONNECTED); + }); + + it("should send the request", () => { + expect(setup.requestServiceSpy.sendRequest).toHaveBeenCalled(); + }); + + it("should clean up and transition to the RsocketRetryingState when the connector is not CONNECTED", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.INITIALIZING); + + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + WaitingForRsocketState, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/state/request/requesting.state.ts b/src/app/services/state/request/requesting.state.ts new file mode 100644 index 0000000..fba41eb --- /dev/null +++ b/src/app/services/state/request/requesting.state.ts @@ -0,0 +1,35 @@ +import { RequestState } from "./request.state"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { StateEnum } from "../StateEnum"; + +export class RequestingState extends RequestState { + constructor(requestService: RequestServiceStateInterface) { + super(requestService); + + console.log("Requesting data..."); + this.requestService.nextRequestState(StateEnum.REQUESTING); + this.requestService.sendRequest(); + + const subscription = this.requestService.connectorState.subscribe( + (state) => { + if ( + state === ConnectorStatesEnum.INITIALIZING || + state === ConnectorStatesEnum.RETRYING + ) { + this.onRsocketDisconnect(); + } + }, + ); + + this.subscriptions.add(subscription); + } + + onRsocketDisconnect(): void { + this.cleanUp(); + + console.error("Rsocket disconnected while requesting data."); + this.requestService.state = new WaitingForRsocketState(this.requestService); + } +} diff --git a/src/app/services/state/request/rsocketRetrying.state.spec.ts b/src/app/services/state/request/rsocketRetrying.state.spec.ts new file mode 100644 index 0000000..facd9ef --- /dev/null +++ b/src/app/services/state/request/rsocketRetrying.state.spec.ts @@ -0,0 +1,30 @@ +import { RsocketRetryingState } from "./rsocketRetrying.state"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { RequestState } from "./request.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { BehaviorSubject } from "rxjs"; +import { setupStateTest } from "./stateHelperSetup.spec"; +import { RequestingState } from "./requesting.state"; + +describe("RsocketRetryingState", () => { + let setup: { + state: any; + requestServiceSpy: jasmine.SpyObj>; + mockConnectorState$: BehaviorSubject; + currentStateContainer: { currentState: RequestState | null }; + }; + + beforeEach(() => { + setup = setupStateTest(RsocketRetryingState, ConnectorStatesEnum.RETRYING); + }); + + it("should clean up and transition to the RequestingState when the connector is CONNECTED", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.CONNECTED); + + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + RequestingState, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/state/request/rsocketRetrying.state.ts b/src/app/services/state/request/rsocketRetrying.state.ts new file mode 100644 index 0000000..1e4044c --- /dev/null +++ b/src/app/services/state/request/rsocketRetrying.state.ts @@ -0,0 +1,29 @@ +import { RequestState } from "./request.state"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { RequestingState } from "./requesting.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; + +export class RsocketRetryingState extends RequestState { + constructor(requestService: RequestServiceStateInterface) { + console.log("Rsocket is retrying..."); + super(requestService); + + const subscription = this.requestService.connectorState.subscribe( + (state) => { + if (state === ConnectorStatesEnum.CONNECTED) { + this.onReady(); + } + }, + ); + + this.subscriptions.add(subscription); + } + + onReady(): void { + this.cleanUp(); + console.log( + "Rsocket connected after retry. Transitioning to RequestingState.", + ); + this.requestService.state = new RequestingState(this.requestService); + } +} diff --git a/src/app/services/state/request/stateHelperSetup.spec.ts b/src/app/services/state/request/stateHelperSetup.spec.ts new file mode 100644 index 0000000..5d9922d --- /dev/null +++ b/src/app/services/state/request/stateHelperSetup.spec.ts @@ -0,0 +1,52 @@ +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { BehaviorSubject } from "rxjs"; +import { RequestState } from "./request.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; + +export function setupStateTest>( + StateClass: new (requestService: RequestServiceStateInterface) => S, + currentConnectorState: ConnectorStatesEnum, +): { + state: S; + requestServiceSpy: jasmine.SpyObj>; + mockConnectorState$: BehaviorSubject; + currentStateContainer: { currentState: RequestState | null }; +} { + const mockConnectorState$ = new BehaviorSubject( + currentConnectorState, + ); + const currentStateContainer: { currentState: RequestState | null } = { + currentState: null, + }; + + const requestServiceSpy = jasmine.createSpyObj< + RequestServiceStateInterface + >("RequestServiceStateInterface", [ + "getEvents$", + "sendRequest", + "cleanUp", + "nextEvent", + "nextRequestState", + ]); + + Object.defineProperty(requestServiceSpy, "connectorState", { + get: () => mockConnectorState$.asObservable(), + }); + + Object.defineProperty(requestServiceSpy, "state", { + get: () => currentStateContainer.currentState, + set: (state: RequestState) => + (currentStateContainer.currentState = state), + configurable: true, + }); + + const state = new StateClass(requestServiceSpy); + currentStateContainer.currentState = state; + + return { + state, + requestServiceSpy, + mockConnectorState$, + currentStateContainer, + }; +} diff --git a/src/app/services/state/request/waitingForRsocket.state.spec.ts b/src/app/services/state/request/waitingForRsocket.state.spec.ts new file mode 100644 index 0000000..c5f3cf2 --- /dev/null +++ b/src/app/services/state/request/waitingForRsocket.state.spec.ts @@ -0,0 +1,48 @@ +import { WaitingForRsocketState } from "./waitingForRsocket.state"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { BehaviorSubject } from "rxjs"; +import { RequestState } from "./request.state"; +import { StateEnum } from "../StateEnum"; +import { RequestingState } from "./requesting.state"; +import { RsocketRetryingState } from "./rsocketRetrying.state"; +import { setupStateTest } from "./stateHelperSetup.spec"; + +describe("WaitingForRsocketState", () => { + let setup: { + state: any; + requestServiceSpy: jasmine.SpyObj>; + mockConnectorState$: BehaviorSubject; + currentStateContainer: { currentState: RequestState | null }; + }; + + beforeEach(() => { + setup = setupStateTest( + WaitingForRsocketState, + ConnectorStatesEnum.INITIALIZING, + ); + }); + + it("should clean up and transition to the RequestingState when the connector is CONNECTED", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.CONNECTED); + + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + RequestingState, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); + + it("should clean up and transition to the RsocketRetrying state when the connector is RETRYING", () => { + spyOn(setup.state, "cleanUp"); + setup.mockConnectorState$.next(ConnectorStatesEnum.RETRYING); + + expect(setup.requestServiceSpy.nextRequestState).toHaveBeenCalledWith( + StateEnum.RETRYING, + ); + expect(setup.currentStateContainer.currentState).toBeInstanceOf( + RsocketRetryingState, + ); + expect(setup.state.cleanUp).toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/state/request/waitingForRsocket.state.ts b/src/app/services/state/request/waitingForRsocket.state.ts new file mode 100644 index 0000000..84ba591 --- /dev/null +++ b/src/app/services/state/request/waitingForRsocket.state.ts @@ -0,0 +1,38 @@ +import { RequestState } from "./request.state"; +import { RequestServiceStateInterface } from "../../network/rsocket/mediators/interfaces/requestServiceState.interface"; +import { RequestingState } from "./requesting.state"; +import { RsocketRetryingState } from "./rsocketRetrying.state"; +import { ConnectorStatesEnum } from "../../network/rsocket/ConnectorStatesEnum"; +import { StateEnum } from "../StateEnum"; + +export class WaitingForRsocketState extends RequestState { + constructor(requestService: RequestServiceStateInterface) { + console.log("Waiting for Rsocket connection to be ready..."); + super(requestService); + + const subscription = this.requestService.connectorState.subscribe( + (state) => { + if (state === ConnectorStatesEnum.CONNECTED) { + this.onReady(); + } else if (state === ConnectorStatesEnum.RETRYING) { + this.onRetrying(); + } + }, + ); + + this.subscriptions.add(subscription); + } + + onReady(): void { + this.cleanUp(); + console.log("Rsocket connected. Transitioning to RequestingState."); + this.requestService.state = new RequestingState(this.requestService); + } + + onRetrying(): void { + this.cleanUp(); + console.log("Rsocket is retrying. Transitioning to RsocketRetryingState."); + this.requestService.nextRequestState(StateEnum.RETRYING); + this.requestService.state = new RsocketRetryingState(this.requestService); + } +} diff --git a/src/app/services/user/notification.service.spec.ts b/src/app/services/user/notification.service.spec.ts deleted file mode 100644 index 55b1b4f..0000000 --- a/src/app/services/user/notification.service.spec.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { NotificationService } from "./notification.service"; -import { TestBed } from "@angular/core/testing"; -import { RsocketPublicUpdateStreamService } from "../network/rsocket/streams/rsocketPublicUpdateStream.service"; -import { RsocketPrivateUpdateStreamService } from "../network/rsocket/streams/rsocketPrivateUpdateStream.service"; -import { MatSnackBar } from "@angular/material/snack-bar"; -import { UserService } from "./user.service"; -import { cold, getTestScheduler } from "jasmine-marbles"; -import { PrivateEventModel } from "../../model/privateEvent.model"; -import { EventTypeEnum } from "../../model/enums/eventType.enum"; -import { EventStatusEnum } from "../../model/enums/eventStatus.enum"; -import { MemberModel } from "../../model/member.model"; -import { ConfigService } from "../../config/config.service"; -import { RETRY_FOREVER } from "../../app-tokens"; -import { RetryForeverConstantService } from "../retry/retryForeverConstant.service"; -import { PublicEventModel } from "../../model/publicEvent.model"; - -describe("NotificationService", () => { - let service: NotificationService; - let rsocketPublicUpdateStreamService: RsocketPublicUpdateStreamService; - let rsocketPrivateUpdateStreamService: RsocketPrivateUpdateStreamService; - let snackBar: MatSnackBar; - let userService: Partial; - - beforeEach(() => { - userService = { - currentGroupId: null, - currentMemberId: null, - }; - - TestBed.configureTestingModule({ - providers: [ - NotificationService, - { provide: UserService, useValue: userService }, - { provide: ConfigService, useValue: {} }, - { provide: RETRY_FOREVER, useValue: RetryForeverConstantService }, - ], - }); - - rsocketPrivateUpdateStreamService = TestBed.inject( - RsocketPrivateUpdateStreamService, - ); - rsocketPublicUpdateStreamService = TestBed.inject( - RsocketPublicUpdateStreamService, - ); - - spyOnProperty( - rsocketPrivateUpdateStreamService, - "isPrivateUpdatesStreamReady$", - ).and.returnValue(cold("a", { a: true })); - spyOnProperty( - rsocketPublicUpdateStreamService, - "isPublicUpdatesStreamReady$", - ).and.returnValue(cold("a", { a: true })); - - snackBar = TestBed.inject(MatSnackBar); - spyOn(snackBar, "open"); - service = TestBed.inject(NotificationService); - }); - - it("should create the notification service", () => { - expect(service).toBeTruthy(); - }); - - describe("private notifications", () => { - describe("group notifications", () => { - describe("joining a group", () => { - it("should show a notification when the user is added to a group", () => { - const member: Partial = { - id: 2, - }; - const privateEvent: Partial = { - eventType: EventTypeEnum.MEMBER_JOINED, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - eventData: JSON.stringify(member), - }; - - spyOnProperty( - rsocketPrivateUpdateStreamService, - "privateUpdatesStream$", - ).and.returnValue(cold("a", { a: privateEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - "Successfully joined group", - jasmine.anything(), - jasmine.anything(), - ); - expect(userService.currentGroupId).toBe(1); - expect(userService.currentMemberId).toBe(2); - }); - - it("should show an error notification when the user fails to be added to a group", () => { - const privateEvent: Partial = { - eventType: EventTypeEnum.MEMBER_JOINED, - eventStatus: EventStatusEnum.FAILED, - aggregateId: 1, - }; - - spyOnProperty( - rsocketPrivateUpdateStreamService, - "privateUpdatesStream$", - ).and.returnValue(cold("a", { a: privateEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - "There was a problem trying to join the group", - jasmine.anything(), - jasmine.anything(), - ); - expect(userService.currentGroupId).toBe(null); - expect(userService.currentMemberId).toBe(null); - }); - }); - - describe("leaving a group", () => { - it("should show a notification when the user is removed from a group", () => { - const privateEvent: Partial = { - eventType: EventTypeEnum.MEMBER_LEFT, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - }; - - spyOnProperty( - rsocketPrivateUpdateStreamService, - "privateUpdatesStream$", - ).and.returnValue(cold("a", { a: privateEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - "Successfully left group", - jasmine.anything(), - jasmine.anything(), - ); - expect(userService.currentGroupId).toBe(null); - expect(userService.currentMemberId).toBe(null); - }); - - describe("error notification", () => { - const triggerLeaveGroupFailure = () => { - const privateEvent: Partial = { - eventType: EventTypeEnum.MEMBER_LEFT, - eventStatus: EventStatusEnum.FAILED, - aggregateId: 1, - }; - - spyOnProperty( - rsocketPrivateUpdateStreamService, - "privateUpdatesStream$", - ).and.returnValue(cold("a", { a: privateEvent })); - - getTestScheduler().flush(); - }; - - it("should show an error notification when the user fails to be removed from a group", () => { - triggerLeaveGroupFailure(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - "There was a problem trying to leave the group", - jasmine.anything(), - jasmine.anything(), - ); - expect(userService.currentGroupId).toBe(null); - expect(userService.currentMemberId).toBe(null); - }); - - it("should not reset a member's group and member id when failing to be removed from a group", () => { - userService.currentGroupId = 1; - userService.currentMemberId = 2; - - triggerLeaveGroupFailure(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - "There was a problem trying to leave the group", - jasmine.anything(), - jasmine.anything(), - ); - expect(userService.currentGroupId).toBe(1); - expect(userService.currentMemberId).toBe(2); - }); - }); - }); - }); - }); - - describe("public notifications", () => { - describe("group notifications", () => { - describe("disbanding a group", () => { - it("should show a notification when the group a user is a part of disbands", () => { - userService.currentGroupId = 1; - - const publicEvent: Partial = { - eventType: EventTypeEnum.GROUP_DISBANDED, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - }; - - spyOnProperty( - rsocketPublicUpdateStreamService, - "publicUpdatesStream$", - ).and.returnValue(cold("a", { a: publicEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - "Group has been disbanded", - jasmine.anything(), - jasmine.anything(), - ); - - expect(userService.currentGroupId as number | null).toBe(null); - }); - it("should not show a notification for a group disbandment for groups the user is not a part of", () => { - userService.currentGroupId = 2; - - const publicEvent: Partial = { - eventType: EventTypeEnum.GROUP_DISBANDED, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - }; - - spyOnProperty( - rsocketPublicUpdateStreamService, - "publicUpdatesStream$", - ).and.returnValue(cold("a", { a: publicEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).not.toHaveBeenCalled(); - expect(userService.currentGroupId).toBe(2); - }); - }); - - describe("joining a group", () => { - it("should show a notification when a user is added to a group", () => { - userService.currentGroupId = 1; - const member: Partial = { - id: 2, - username: "Captain Qwark", - }; - - const publicEvent: Partial = { - eventType: EventTypeEnum.MEMBER_JOINED, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - eventData: JSON.stringify(member), - }; - - spyOnProperty( - rsocketPublicUpdateStreamService, - "publicUpdatesStream$", - ).and.returnValue(cold("a", { a: publicEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - `${member.username} joined the group`, - jasmine.anything(), - jasmine.anything(), - ); - }); - - it("should not show a notification for a member joining a group the user is not a part of", () => { - userService.currentGroupId = 2; - const member: Partial = { - id: 2, - username: "Captain Qwark", - }; - - const publicEvent: Partial = { - eventType: EventTypeEnum.MEMBER_JOINED, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - eventData: JSON.stringify(member), - }; - - spyOnProperty( - rsocketPublicUpdateStreamService, - "publicUpdatesStream$", - ).and.returnValue(cold("a", { a: publicEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).not.toHaveBeenCalled(); - }); - }); - - describe("leaving a group", () => { - it("should show a notification when a user is removed from a group", () => { - userService.currentGroupId = 1; - const member: Partial = { - id: 2, - username: "Captain Qwark", - }; - - const publicEvent: Partial = { - eventType: EventTypeEnum.MEMBER_LEFT, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - eventData: JSON.stringify(member), - }; - - spyOnProperty( - rsocketPublicUpdateStreamService, - "publicUpdatesStream$", - ).and.returnValue(cold("a", { a: publicEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).toHaveBeenCalledOnceWith( - `${member.username} left the group`, - jasmine.anything(), - jasmine.anything(), - ); - }); - - it("should not show a notification for a member leaving a group the user is not a part of", () => { - userService.currentGroupId = 2; - const member: Partial = { - id: 2, - username: "Captain Qwark", - }; - - const publicEvent: Partial = { - eventType: EventTypeEnum.MEMBER_LEFT, - eventStatus: EventStatusEnum.SUCCESSFUL, - aggregateId: 1, - eventData: JSON.stringify(member), - }; - - spyOnProperty( - rsocketPublicUpdateStreamService, - "publicUpdatesStream$", - ).and.returnValue(cold("a", { a: publicEvent })); - - getTestScheduler().flush(); - - expect(snackBar.open).not.toHaveBeenCalled(); - }); - }); - }); - }); -}); diff --git a/src/app/services/user/notification.service.ts b/src/app/services/user/notification.service.ts deleted file mode 100644 index 9b0c135..0000000 --- a/src/app/services/user/notification.service.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Injectable } from "@angular/core"; -import { PrivateEventModel } from "../../model/privateEvent.model"; -import { EventTypeEnum } from "../../model/enums/eventType.enum"; -import { EventStatusEnum } from "../../model/enums/eventStatus.enum"; -import { MemberModel } from "../../model/member.model"; -import { UserService } from "./user.service"; -import { PublicEventModel } from "../../model/publicEvent.model"; -import { MatSnackBar } from "@angular/material/snack-bar"; -import { RsocketPublicUpdateStreamService } from "../network/rsocket/streams/rsocketPublicUpdateStream.service"; -import { RsocketPrivateUpdateStreamService } from "../network/rsocket/streams/rsocketPrivateUpdateStream.service"; - -@Injectable({ - providedIn: "root", -}) -export class NotificationService { - constructor( - private readonly _snackbar: MatSnackBar, - private readonly rsocketPublicUpdateStreamService: RsocketPublicUpdateStreamService, - private readonly rsocketPrivateUpdateStreamService: RsocketPrivateUpdateStreamService, - private readonly userService: UserService, - ) { - this.monitorPrivateUpdatesStream(); - this.monitorPublicUpdatesStream(); - } - - private monitorPrivateUpdatesStream() { - this.rsocketPrivateUpdateStreamService.isPrivateUpdatesStreamReady$.subscribe( - (status) => { - if (status) { - this.subscribeToPrivateUpdatesStream(); - } - }, - ); - } - - private subscribeToPrivateUpdatesStream() { - this.rsocketPrivateUpdateStreamService.privateUpdatesStream$.subscribe({ - next: (privateEvent) => { - console.debug("Received private event: ", privateEvent); - const message = this.privateEventMessage(privateEvent); - - if (message) { - this.showMessage(message); - } - }, - error: (error) => { - console.error("Error receiving private event: ", error); - }, - complete: () => {}, - }); - } - - private monitorPublicUpdatesStream() { - this.rsocketPublicUpdateStreamService.isPublicUpdatesStreamReady$.subscribe( - (status) => { - if (status) { - this.subscribeToPublicUpdatesStream(); - } - }, - ); - } - - private subscribeToPublicUpdatesStream() { - this.rsocketPublicUpdateStreamService.publicUpdatesStream$.subscribe({ - next: (publicEvent) => { - console.debug("Received public event: ", publicEvent); - const message = this.publicEventMessage(publicEvent); - - if (message) { - this.showMessage(message); - } - }, - error: (error) => { - console.error("Error receiving public event: ", error); - }, - complete: () => {}, - }); - } - - private privateEventMessage( - privateEvent: PrivateEventModel, - ): string | undefined { - switch (privateEvent.eventType) { - case EventTypeEnum.MEMBER_JOINED: - if (privateEvent.eventStatus === EventStatusEnum.SUCCESSFUL) { - this.userService.currentGroupId = privateEvent.aggregateId; - - const member = this.parseIfJson( - privateEvent.eventData, - ) as MemberModel; - - this.userService.currentMemberId = member.id; - return "Successfully joined group"; - } else { - return "There was a problem trying to join the group"; - } - case EventTypeEnum.MEMBER_LEFT: - if (privateEvent.eventStatus === EventStatusEnum.SUCCESSFUL) { - this.userService.currentGroupId = null; - this.userService.currentMemberId = null; - return "Successfully left group"; - } else { - return "There was a problem trying to leave the group"; - } - } - - return; - } - - private publicEventMessage( - publicEvent: PublicEventModel, - ): string | undefined { - if ( - publicEvent.eventStatus !== EventStatusEnum.SUCCESSFUL || - publicEvent.aggregateId !== this.userService.currentGroupId - ) { - return; - } - - switch (publicEvent.eventType) { - case EventTypeEnum.GROUP_DISBANDED: - this.userService.currentGroupId = null; - return "Group has been disbanded"; - case EventTypeEnum.MEMBER_JOINED: - case EventTypeEnum.MEMBER_LEFT: - if (publicEvent.aggregateId === this.userService.currentGroupId) { - const member = this.parseIfJson(publicEvent.eventData) as MemberModel; - - return ( - member.username + - (publicEvent.eventType === EventTypeEnum.MEMBER_JOINED - ? " joined the group" - : " left the group") - ); - } - } - - return; - } - - private showMessage(message: string) { - console.debug("Sending message: ", message); - this._snackbar.open(message, "Dismiss", { - verticalPosition: "top", - duration: 5000, - }); - } - - private parseIfJson(maybeJson: string | T): T { - if (typeof maybeJson === "string") { - try { - maybeJson = JSON.parse(maybeJson) as T; - } catch (error) { - console.error( - `Error parsing event data to object for ${maybeJson}`, - error, - ); - throw new Error("Invalid JSON string"); - } - } - - return maybeJson as T; - } -} diff --git a/src/app/services/user/user.service.spec.ts b/src/app/services/user/user.service.spec.ts index eb1159a..e75a5b2 100644 --- a/src/app/services/user/user.service.spec.ts +++ b/src/app/services/user/user.service.spec.ts @@ -1,42 +1,15 @@ import { TestBed } from "@angular/core/testing"; import { UserService } from "./user.service"; -import { RsocketService } from "../network/rsocket/rsocket.service"; -import { RsocketRequestsService } from "../network/rsocket/requests/rsocketRequests.service"; -import { RsocketPrivateUpdateStreamService } from "../network/rsocket/streams/rsocketPrivateUpdateStream.service"; import { ConfigService } from "../../config/config.service"; -import { RETRY_FOREVER } from "../../app-tokens"; -import { RetryForeverConstantService } from "../retry/retryForeverConstant.service"; -import { cold, getTestScheduler } from "jasmine-marbles"; describe("UserService", () => { let service: UserService; - let rsocketService: RsocketService; - let rsocketRequestsService: RsocketRequestsService; - let rsocketPrivateUpdateStreamService: RsocketPrivateUpdateStreamService; beforeEach(() => { TestBed.configureTestingModule({ - providers: [ - UserService, - { provide: ConfigService, useValue: {} }, - { provide: RETRY_FOREVER, useValue: RetryForeverConstantService }, - ], + providers: [UserService, { provide: ConfigService, useValue: {} }], }); - rsocketService = TestBed.inject(RsocketService); - spyOn(rsocketService, "initializeRsocketConnection"); - - rsocketPrivateUpdateStreamService = TestBed.inject( - RsocketPrivateUpdateStreamService, - ); - spyOn(rsocketPrivateUpdateStreamService, "initializePrivateUpdateStream"); - - rsocketRequestsService = TestBed.inject(RsocketRequestsService); - spyOnProperty(rsocketService, "isConnectionReady$").and.returnValue( - cold("a", { a: true }), - ); - spyOn(rsocketRequestsService, "currentMemberForUser"); - service = TestBed.inject(UserService); }); @@ -52,6 +25,20 @@ describe("UserService", () => { expect(service.uuid).toEqual(service.uuid); }); + describe("user group membership operations", () => { + it("should set the current group and member id", () => { + service.setUserInGroup(1, 1); + expect(service.currentGroupId).toBe(1); + expect(service.currentMemberId).toBe(1); + }); + + it("should clear the current group and member id", () => { + service.removeUserFromGroup(); + expect(service.currentGroupId).toBeNull(); + expect(service.currentMemberId).toBeNull(); + }); + }); + it("should return a different uuid when localStorage's uuid is cleared", () => { const uuid = service.uuid; localStorage.removeItem(service.MY_UUID_KEY); @@ -67,15 +54,4 @@ describe("UserService", () => { expect(service.uuid).not.toEqual("test"); expect(service.uuid).toBeTruthy(); }); - - it("should set the user's current member when connected to the server", () => { - expect(rsocketService.initializeRsocketConnection).toHaveBeenCalled(); - - getTestScheduler().flush(); - - expect(rsocketRequestsService.currentMemberForUser).toHaveBeenCalledWith( - jasmine.any(Function), - service.uuid, - ); - }); }); diff --git a/src/app/services/user/user.service.ts b/src/app/services/user/user.service.ts index acdfd74..84097fd 100644 --- a/src/app/services/user/user.service.ts +++ b/src/app/services/user/user.service.ts @@ -1,9 +1,7 @@ import { Injectable } from "@angular/core"; import { v4 as uuidv4 } from "uuid"; -import { RsocketRequestsService } from "../network/rsocket/requests/rsocketRequests.service"; -import { RsocketService } from "../network/rsocket/rsocket.service"; -import { RsocketPrivateUpdateStreamService } from "../network/rsocket/streams/rsocketPrivateUpdateStream.service"; import { BehaviorSubject } from "rxjs"; + @Injectable({ providedIn: "root", }) @@ -13,17 +11,8 @@ export class UserService { private _currentGroupId = new BehaviorSubject(null); private _currentMemberId = new BehaviorSubject(null); - constructor( - private readonly rsocketService: RsocketService, - private readonly rsocketRequestsService: RsocketRequestsService, - private readonly rsocketPrivateUpdateStreamService: RsocketPrivateUpdateStreamService, - ) { + constructor() { this.myUuid = this.saveOrGetUuidFromLocalStorage(); - rsocketService.initializeRsocketConnection(this.uuid); - this.rsocketPrivateUpdateStreamService.initializePrivateUpdateStream( - this.uuid, - ); - this.initializeCurrentMember(); } get currentGroupId$() { @@ -38,31 +27,14 @@ export class UserService { return this._currentMemberId.getValue(); } - set currentGroupId(value: number | null) { - this._currentGroupId.next(value); - } - - set currentMemberId(value: number | null) { - this._currentMemberId.next(value); + public setUserInGroup(groupId: number, memberId: number) { + this._currentGroupId.next(groupId); + this._currentMemberId.next(memberId); } - private initializeCurrentMember() { - const subscription = this.rsocketService.isConnectionReady$.subscribe( - (status) => { - if (status) { - console.debug("Connection ready. Sending member request"); - - this.rsocketRequestsService.currentMemberForUser((member) => { - if (member) { - this._currentGroupId.next(member?.groupId); - this._currentMemberId.next(member?.id); - } - }, this.uuid); - - subscription.unsubscribe(); - } - }, - ); + public removeUserFromGroup() { + this._currentGroupId.next(null); + this._currentMemberId.next(null); } private saveOrGetUuidFromLocalStorage() { diff --git a/src/app/shared/loading/loading.component.html b/src/app/shared/loading/loading.component.html index 82a3a39..944cfb9 100644 --- a/src/app/shared/loading/loading.component.html +++ b/src/app/shared/loading/loading.component.html @@ -9,37 +9,47 @@ }" data-test="loading__component" > - @if (loading) { @if (nextRetry === null) { -

- @if (itemName === null) { Loading... } @else { Loading {{ itemName }}... } -

- } @else { -
- @if (nextRetry && nextRetry > 0) { -

- Failed to load groups
Retrying in {{ nextRetry }} seconds... -

+ @if (loading) { + @if (nextRetry === null) { +

+ @if (itemName === null) { + Loading... + } @else { + Loading {{ itemName }}... + } +

} @else { -

Retrying...

+
+ @if (nextRetry && nextRetry > 0) { +

+ Failed to load groups
Retrying in {{ nextRetry }} seconds... +

+ } @else { +

+ Retrying... +

+ } +
+ } + @if (nextRetry === null || nextRetry <= 0) { + } -
- } @if (nextRetry === null || nextRetry <= 0) { - - } } @else { -

- Failed to load{{ itemName ? " " + itemName : "" }}.
Try again? -

- } @if (retryFunction !== null && shouldShowRetryButton) { - + } @else { +

+ Failed to load{{ itemName ? " " + itemName : "" }}.
Try again? +

+ } + @if (retryFunction !== null && shouldShowRetryButton) { + } diff --git a/src/app/shared/nav/nav.component.html b/src/app/shared/nav/nav.component.html index 29bf40a..a327146 100644 --- a/src/app/shared/nav/nav.component.html +++ b/src/app/shared/nav/nav.component.html @@ -1,115 +1,123 @@ diff --git a/src/app/shared/syncBanner/syncBanner.component.html b/src/app/shared/syncBanner/syncBanner.component.html index 3ada5b4..85421c9 100644 --- a/src/app/shared/syncBanner/syncBanner.component.html +++ b/src/app/shared/syncBanner/syncBanner.component.html @@ -1,12 +1,12 @@
@if (!syncedTextState) { -

- Uh oh! We're having trouble syncing you with the latest group info. To get - the latest info, try refreshing the page. -

+

+ Uh oh! We're having trouble syncing you with the latest group info. We'll + get you back in sync as soon as we can. +

} @else { -

- You're now synced with the latest group info! -

+

+ You're now synced with the latest group info! +

}
diff --git a/src/config/config.json b/src/config/config.json index b5d1ec7..600165b 100644 --- a/src/config/config.json +++ b/src/config/config.json @@ -19,12 +19,12 @@ "retryDefault": { "MAX_RETRY_ATTEMPTS": 2, "MIN_RETRY_INTERVAL": 3, - "MAX_RETRY_INTERVAL": 60 + "MAX_RETRY_INTERVAL": 30 }, "retryForeverConstant": { "MAX_RETRY_ATTEMPTS": -1, "MIN_RETRY_INTERVAL": 5, - "MAX_RETRY_INTERVAL": -1 + "MAX_RETRY_INTERVAL": 15 } }, "groupBoardComponent": { diff --git a/src/main.ts b/src/main.ts index 3eaeab8..70f6c69 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,9 +7,6 @@ import { import { provideAnimations } from "@angular/platform-browser/animations"; import { AppRoutes } from "./app/app-routes"; import { BrowserModule, bootstrapApplication } from "@angular/platform-browser"; -import { RetryForeverConstantService } from "./app/services/retry/retryForeverConstant.service"; -import { RetryDefaultService } from "./app/services/retry/retryDefault.service"; -import { RETRY_DEFAULT, RETRY_FOREVER } from "./app/app-tokens"; import { APP_CONFIG } from "./app/config/config"; import { provideRouter } from "@angular/router"; @@ -20,8 +17,6 @@ fetch("./config/config.json") providers: [ importProvidersFrom(BrowserModule), { provide: APP_CONFIG, useValue: config }, - { provide: RETRY_DEFAULT, useClass: RetryDefaultService }, - { provide: RETRY_FOREVER, useClass: RetryForeverConstantService }, provideAnimations(), provideHttpClient(withInterceptorsFromDi()), provideRouter(AppRoutes),