From 70da42f8b8791bfa7c5569e9f705598b56ca85c3 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 15 Jan 2022 22:15:28 +0800 Subject: [PATCH] refactor: config service --- package.json | 3 +- pnpm-lock.yaml | 112 ++++++++++++--- src/bootstrap.ts | 3 +- src/constants/event.constant.ts | 4 + src/modules/configs/configs.service.ts | 88 +++++++++++- src/modules/init/init.controller.ts | 5 +- src/modules/option/option.controller.ts | 5 +- src/modules/option/option.module.ts | 3 - src/modules/option/option.service.ts | 77 ---------- src/processors/helper/helper.cron.service.ts | 3 + src/processors/helper/helper.email.service.ts | 3 + src/processors/helper/helper.module.ts | 16 +++ test/helper/redis-mock.helper.ts | 53 +++++++ .../modules/configs/configs.service.spec.ts | 132 ++++++++++++++++++ .../snippet/snippet.controller.e2e-spec.ts | 53 ++++--- .../modules/snippet/snippet.service.spec.ts | 2 +- 16 files changed, 420 insertions(+), 142 deletions(-) create mode 100644 src/constants/event.constant.ts delete mode 100644 src/modules/option/option.service.ts create mode 100644 test/helper/redis-mock.helper.ts create mode 100644 test/src/modules/configs/configs.service.spec.ts diff --git a/package.json b/package.json index 684152af..990c7f6d 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@algolia/client-search": "*", "@nestjs/common": "8.2.5", "@nestjs/core": "8.2.5", + "@nestjs/event-emitter": "1.0.0", "@nestjs/graphql": "9.1.2", "@nestjs/jwt": "8.0.0", "@nestjs/mapped-types": "*", @@ -148,9 +149,9 @@ "ioredis": "4.28.3", "jest": "27.4.7", "lint-staged": "12.1.7", - "mockingoose": "2.15.2", "mongodb-memory-server": "8.1.0", "prettier": "2.5.1", + "redis-memory-server": "0.5.0", "rimraf": "3.0.2", "run-script-webpack-plugin": "0.0.11", "semver": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b344f5d6..6ee27d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,7 @@ specifiers: '@nestjs/cli': 8.1.8 '@nestjs/common': 8.2.5 '@nestjs/core': 8.2.5 + '@nestjs/event-emitter': 1.0.0 '@nestjs/graphql': 9.1.2 '@nestjs/jwt': 8.0.0 '@nestjs/mapped-types': '*' @@ -70,9 +71,8 @@ specifiers: jszip: 3.7.1 lint-staged: 12.1.7 lodash: '*' - marked: 4.0.10 + marked: 4.0.9 mkdirp: '*' - mockingoose: 2.15.2 mongodb-memory-server: 8.1.0 mongoose: '*' mongoose-lean-id: 0.3.0 @@ -87,6 +87,7 @@ specifiers: passport-jwt: 4.0.0 pluralize: '*' prettier: 2.5.1 + redis-memory-server: 0.5.0 reflect-metadata: 0.1.13 rimraf: 3.0.2 run-script-webpack-plugin: 0.0.11 @@ -109,6 +110,7 @@ dependencies: '@algolia/client-search': 4.12.0 '@nestjs/common': 8.2.5_17d6ce38cc74803538e351a8f2ca91ba '@nestjs/core': 8.2.5_b47d24345f8c2ad123c7afb4f52057f0 + '@nestjs/event-emitter': 1.0.0_8b618827ffa1784f2e7b042a97d87b6c '@nestjs/graphql': 9.1.2_4aece87669e37e111f6a4461a47eae19 '@nestjs/jwt': 8.0.0_@nestjs+common@8.2.5 '@nestjs/mapped-types': 1.0.1_156c8e2af2c7dad431aaf14c3b14437b @@ -145,7 +147,7 @@ dependencies: js-yaml: 4.1.0 jszip: 3.7.1 lodash: 4.17.21 - marked: 4.0.10 + marked: 4.0.9 mkdirp: 1.0.4 mongoose: 6.1.6 mongoose-lean-id: 0.3.0_mongoose@6.1.6 @@ -193,9 +195,9 @@ devDependencies: ioredis: 4.28.3 jest: 27.4.7_ts-node@10.4.0 lint-staged: 12.1.7 - mockingoose: 2.15.2_mongoose@6.1.6 mongodb-memory-server: 8.1.0 prettier: 2.5.1 + redis-memory-server: 0.5.0 rimraf: 3.0.2 run-script-webpack-plugin: 0.0.11 semver: 7.3.5 @@ -1420,6 +1422,19 @@ packages: uuid: 8.3.2 dev: false + /@nestjs/event-emitter/1.0.0_8b618827ffa1784f2e7b042a97d87b6c: + resolution: {integrity: sha512-dRAou6G89KKYI2iyYfqSVGE6ZTC4WmHkQkFfgh88GLQg8dBqRk92ZY8CRtL2SK32SSelh9bwEDNQn9561uoypA==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 + reflect-metadata: ^0.1.12 + dependencies: + '@nestjs/common': 8.2.5_17d6ce38cc74803538e351a8f2ca91ba + '@nestjs/core': 8.2.5_b47d24345f8c2ad123c7afb4f52057f0 + eventemitter2: 6.4.4 + reflect-metadata: 0.1.13 + dev: false + /@nestjs/graphql/9.1.2_4aece87669e37e111f6a4461a47eae19: resolution: {integrity: sha512-ncxmkKmrswnHJ+jLgc6tbkRET6HAyC3gK6WKt1CdPLjvMnXVpFVpyQMFSenNXxAndInw9IgaiSyiJFdRYZVD/w==} peerDependencies: @@ -2137,6 +2152,14 @@ packages: '@types/yargs-parser': 20.2.1 dev: true + /@types/yauzl/2.9.2: + resolution: {integrity: sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==} + requiresBuild: true + dependencies: + '@types/node': 16.11.19 + dev: true + optional: true + /@typescript-eslint/eslint-plugin/4.33.0_3289a875d95a672b97ebf589745c66ef: resolution: {integrity: sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3269,7 +3292,6 @@ packages: /chownr/2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} - dev: false /chrome-trace-event/1.0.3: resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} @@ -4221,6 +4243,10 @@ packages: through: 2.3.8 dev: false + /eventemitter2/6.4.4: + resolution: {integrity: sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw==} + dev: false + /eventemitter3/3.1.2: resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} dev: false @@ -4291,6 +4317,20 @@ packages: iconv-lite: 0.4.24 tmp: 0.0.33 + /extract-zip/2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + dependencies: + debug: 4.3.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.9.2 + transitivePeerDependencies: + - supports-color + dev: true + /extsprintf/1.3.0: resolution: {integrity: sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=} engines: {'0': node >=0.6.0} @@ -4515,6 +4555,10 @@ packages: semver-store: 0.3.0 dev: false + /find-package-json/1.2.0: + resolution: {integrity: sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==} + dev: true + /find-up/2.1.0: resolution: {integrity: sha1-RdG35QbHF93UgndaK3eSCjwMV6c=} engines: {node: '>=4'} @@ -4657,7 +4701,6 @@ packages: engines: {node: '>= 8'} dependencies: minipass: 3.1.3 - dev: false /fs-monkey/1.0.3: resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==} @@ -6178,9 +6221,19 @@ packages: p-locate: 4.1.0 dev: true + /lockfile/1.0.4: + resolution: {integrity: sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==} + dependencies: + signal-exit: 3.0.5 + dev: true + /lodash.defaults/4.2.0: resolution: {integrity: sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=} + /lodash.defaultsdeep/4.6.1: + resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==} + dev: true + /lodash.flatten/4.4.0: resolution: {integrity: sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=} @@ -6317,8 +6370,8 @@ packages: resolution: {integrity: sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=} dev: false - /marked/4.0.10: - resolution: {integrity: sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw==} + /marked/4.0.9: + resolution: {integrity: sha512-HmoFvQwFLxNESeGupeOC+6CLb5WzcCWQmqvVetsErmrI3vrZ6gBumty5IP0ynLPR0zYSoVY7ITC1GffsYIGkog==} engines: {node: '>= 12'} hasBin: true dev: false @@ -6408,7 +6461,6 @@ packages: engines: {node: '>=8'} dependencies: yallist: 4.0.0 - dev: false /minizlib/2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} @@ -6416,7 +6468,6 @@ packages: dependencies: minipass: 3.1.3 yallist: 4.0.0 - dev: false /mkdirp/0.5.5: resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} @@ -6429,16 +6480,6 @@ packages: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true - dev: false - - /mockingoose/2.15.2_mongoose@6.1.6: - resolution: {integrity: sha512-50mtbAk29Go5hdhzqTmjmE67Z/cB0yPz45u2jrHoGm4nkYnnBq224viWgyKwnxzWw8birnqn98viM2cRBTnJvw==} - engines: {node: '>=6.4.0'} - peerDependencies: - mongoose: '>=4.9.10' - dependencies: - mongoose: 6.1.6 - dev: true /moment-timezone/0.5.33: resolution: {integrity: sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==} @@ -7292,6 +7333,31 @@ packages: resolution: {integrity: sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=} engines: {node: '>=4'} + /redis-memory-server/0.5.0: + resolution: {integrity: sha512-MoqPXeB9pqUa/Klry6HZw94aaOSE8CAB+1Zo1hXvGu/b+k/u79e2u1/CVGsqJ0xJMSkQMPa9duzzdmXll0PCSg==} + engines: {node: '>=10.15.0'} + requiresBuild: true + dependencies: + camelcase: 6.3.0 + cross-spawn: 7.0.3 + debug: 4.3.3 + extract-zip: 2.0.1 + find-cache-dir: 3.3.2 + find-package-json: 1.2.0 + get-port: 5.1.1 + https-proxy-agent: 5.0.0 + lockfile: 1.0.4 + lodash.defaultsdeep: 4.6.1 + mkdirp: 1.0.4 + rimraf: 3.0.2 + semver: 7.3.5 + tar: 6.1.11 + tmp: 0.2.1 + uuid: 8.3.0 + transitivePeerDependencies: + - supports-color + dev: true + /redis-parser/3.0.0: resolution: {integrity: sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=} engines: {node: '>=4'} @@ -8035,7 +8101,6 @@ packages: minizlib: 2.1.2 mkdirp: 1.0.4 yallist: 4.0.0 - dev: false /terminal-link/2.1.1: resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} @@ -8469,6 +8534,11 @@ packages: hasBin: true dev: false + /uuid/8.3.0: + resolution: {integrity: sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==} + hasBin: true + dev: true + /uuid/8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 787b4267..3571ed6a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -10,8 +10,6 @@ import { SpiderGuard } from './common/guard/spider.guard' import { LoggingInterceptor } from './common/interceptors/logging.interceptor' import { MyLogger } from './processors/logger/logger.service' -console.log('ENV:', process.env.NODE_ENV) - const Origin = CROSS_DOMAIN.allowedOrigins declare const module: any @@ -70,6 +68,7 @@ async function bootstrap() { await app.listen(+PORT, '0.0.0.0', async (err, address) => { app.useLogger(app.get(MyLogger)) + consola.info('ENV:', process.env.NODE_ENV) const url = await app.getUrl() if (isDev) { consola.debug(`OpenApi: ${url}/api-docs`) diff --git a/src/constants/event.constant.ts b/src/constants/event.constant.ts new file mode 100644 index 00000000..0295e79c --- /dev/null +++ b/src/constants/event.constant.ts @@ -0,0 +1,4 @@ +export enum EventBusEvents { + EmailInit = 'email.init', + PushSearch = 'search.push', +} diff --git a/src/modules/configs/configs.service.ts b/src/modules/configs/configs.service.ts index b7d879f4..5d4acd9ad 100644 --- a/src/modules/configs/configs.service.ts +++ b/src/modules/configs/configs.service.ts @@ -1,19 +1,42 @@ -import { Injectable, Logger } from '@nestjs/common' +import { + BadRequestException, + Injectable, + Logger, + ValidationPipe, +} from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' import { DocumentType, ReturnModelType } from '@typegoose/typegoose' import { BeAnObject } from '@typegoose/typegoose/lib/types' +import camelcaseKeys from 'camelcase-keys' +import { ClassConstructor, plainToClass } from 'class-transformer' +import { validateSync, ValidatorOptions } from 'class-validator' import { cloneDeep, mergeWith } from 'lodash' import { LeanDocument } from 'mongoose' import { InjectModel } from 'nestjs-typegoose' import { API_VERSION } from '~/app.config' import { RedisKeys } from '~/constants/cache.constant' +import { EventBusEvents } from '~/constants/event.constant' import { CacheService } from '~/processors/cache/cache.service' import { sleep } from '~/utils/index.util' import { getRedisKey } from '~/utils/redis.util' +import * as optionDtos from '../configs/configs.dto' import { UserModel } from '../user/user.model' import { UserService } from '../user/user.service' -import { BackupOptionsDto, MailOptionsDto } from './configs.dto' +import { + AlgoliaSearchOptionsDto, + BackupOptionsDto, + MailOptionsDto, +} from './configs.dto' import { IConfig } from './configs.interface' import { OptionModel } from './configs.model' +const map: Record = Object.entries(optionDtos).reduce( + (obj, [key, value]) => ({ + ...obj, + [`${key.charAt(0).toLowerCase() + key.slice(1).replace(/Dto$/, '')}`]: + value, + }), + {}, +) const generateDefaultConfig: () => IConfig = () => ({ seo: { @@ -50,6 +73,8 @@ export class ConfigsService { private readonly optionModel: ReturnModelType, private readonly userService: UserService, private readonly redis: CacheService, + + private readonly eventEmitter: EventEmitter2, ) { this.configInit().then(() => { this.logger.log('Config 已经加载完毕!') @@ -131,7 +156,10 @@ export class ConfigsService { } } - public async patch(key: T, data: IConfig[T]) { + public async patch( + key: T, + data: Partial, + ): Promise { const config = await this.getConfig() const updatedConfigRow = await this.optionModel .findOneAndUpdate( @@ -155,6 +183,60 @@ export class ConfigsService { return newData } + validOptions: ValidatorOptions = { + whitelist: true, + forbidNonWhitelisted: true, + } + validate = new ValidationPipe(this.validOptions) + async patchAndValid( + key: T, + value: Partial, + ) { + value = camelcaseKeys(value, { deep: true }) as any + + switch (key) { + case 'mailOptions': { + const option = await this.patch( + 'mailOptions', + this.validWithDto(MailOptionsDto, value), + ) + if (option.enable) { + this.eventEmitter.emit(EventBusEvents.EmailInit) + } + + return option + } + + case 'algoliaSearchOptions': { + const option = await this.patch( + 'algoliaSearchOptions', + this.validWithDto(AlgoliaSearchOptionsDto, value), + ) + if (option.enable) { + this.eventEmitter.emit(EventBusEvents.PushSearch) + } + return option + } + default: { + const dto = map[key] + if (!dto) { + throw new BadRequestException('设置不存在') + } + return this.patch(key, this.validWithDto(dto, value)) + } + } + } + + private validWithDto(dto: ClassConstructor, value: any) { + const validModel = plainToClass(dto, value) + const errors = validateSync(validModel, this.validOptions) + if (errors.length > 0) { + const error = this.validate.createExceptionFactory()(errors as any[]) + throw error + } + return validModel + } + get getMaster() { // HINT: 需要注入 this 的指向 return this.userService.getMaster.bind(this.userService) as () => Promise< diff --git a/src/modules/init/init.controller.ts b/src/modules/init/init.controller.ts index 00712d2d..9c5f8e8f 100644 --- a/src/modules/init/init.controller.ts +++ b/src/modules/init/init.controller.ts @@ -12,7 +12,6 @@ import { import { ApiName } from '~/common/decorator/openapi.decorator' import { ConfigsService } from '../configs/configs.service' import { ConfigKeyDto } from '../option/dtos/config.dto' -import { OptionService } from '../option/option.service' import { InitService } from './init.service' @Controller({ @@ -23,7 +22,7 @@ import { InitService } from './init.service' export class InitController { constructor( private readonly configs: ConfigsService, - private readonly optionService: OptionService, + private readonly initService: InitService, ) {} @@ -55,6 +54,6 @@ export class InitController { if (typeof body !== 'object') { throw new UnprocessableEntityException('body must be object') } - return this.optionService.patchAndValid(params.key, body) + return this.configs.patchAndValid(params.key, body) } } diff --git a/src/modules/option/option.controller.ts b/src/modules/option/option.controller.ts index 94fca571..6951c51f 100644 --- a/src/modules/option/option.controller.ts +++ b/src/modules/option/option.controller.ts @@ -21,14 +21,13 @@ import { IConfig } from '../configs/configs.interface' import { ConfigsService } from '../configs/configs.service' import { ConfigKeyDto } from './dtos/config.dto' import { ReplyEmailBodyDto, ReplyEmailTypeDto } from './dtos/email.dto' -import { OptionService } from './option.service' @Controller(['options', 'config', 'setting', 'configs', 'option']) @ApiTags('Option Routes') @Auth() export class OptionController { constructor( - private readonly optionService: OptionService, + private readonly configsService: ConfigsService, private readonly configs: ConfigsService, private readonly emailService: EmailService, ) {} @@ -57,7 +56,7 @@ export class OptionController { if (typeof body !== 'object') { throw new UnprocessableEntityException('body must be object') } - return this.optionService.patchAndValid(params.key, body) + return this.configsService.patchAndValid(params.key, body) } @Get('/email/template/reply') diff --git a/src/modules/option/option.module.ts b/src/modules/option/option.module.ts index b7c4ffb9..e286d141 100644 --- a/src/modules/option/option.module.ts +++ b/src/modules/option/option.module.ts @@ -1,12 +1,9 @@ import { Module } from '@nestjs/common' import { GatewayModule } from '~/processors/gateway/gateway.module' import { OptionController } from './option.controller' -import { OptionService } from './option.service' @Module({ imports: [GatewayModule], controllers: [OptionController], - providers: [OptionService], - exports: [OptionService], }) export class OptionModule {} diff --git a/src/modules/option/option.service.ts b/src/modules/option/option.service.ts deleted file mode 100644 index 5ad66122..00000000 --- a/src/modules/option/option.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { BadRequestException, Injectable, ValidationPipe } from '@nestjs/common' -import camelcaseKeys from 'camelcase-keys' -import { ClassConstructor, plainToClass } from 'class-transformer' -import { validateSync, ValidatorOptions } from 'class-validator' -import { CronService } from '~/processors/helper/helper.cron.service' -import { EmailService } from '~/processors/helper/helper.email.service' -import * as optionDtos from '../configs/configs.dto' -import { AlgoliaSearchOptionsDto, MailOptionsDto } from '../configs/configs.dto' -import { IConfig } from '../configs/configs.interface' -import { ConfigsService } from '../configs/configs.service' - -const map: Record = Object.entries(optionDtos).reduce( - (obj, [key, value]) => ({ - ...obj, - [`${key.charAt(0).toLowerCase() + key.slice(1).replace(/Dto$/, '')}`]: - value, - }), - {}, -) - -@Injectable() -export class OptionService { - constructor( - private readonly configs: ConfigsService, - private readonly emailService: EmailService, - private readonly cronService: CronService, - ) {} - - validOptions: ValidatorOptions = { - whitelist: true, - forbidNonWhitelisted: true, - } - validate = new ValidationPipe(this.validOptions) - async patchAndValid(key: T, value: IConfig[T]) { - value = camelcaseKeys(value, { deep: true }) as any - - switch (key) { - case 'mailOptions': { - const option = await this.configs.patch( - 'mailOptions', - this.validWithDto(MailOptionsDto, value), - ) - this.emailService.init() - - return option - } - - case 'algoliaSearchOptions': { - const option = await this.configs.patch( - 'algoliaSearchOptions', - this.validWithDto(AlgoliaSearchOptionsDto, value), - ) - if (option.enable) { - this.cronService.pushToAlgoliaSearch() - } - return option - } - default: { - const dto = map[key] - if (!dto) { - throw new BadRequestException('设置不存在') - } - return this.configs.patch(key, this.validWithDto(dto, value)) - } - } - } - - private validWithDto(dto: ClassConstructor, value: any) { - const validModel = plainToClass(dto, value) - const errors = validateSync(validModel, this.validOptions) - if (errors.length > 0) { - const error = this.validate.createExceptionFactory()(errors as any[]) - throw error - } - return validModel - } -} diff --git a/src/processors/helper/helper.cron.service.ts b/src/processors/helper/helper.cron.service.ts index c6c76a23..32681b1b 100644 --- a/src/processors/helper/helper.cron.service.ts +++ b/src/processors/helper/helper.cron.service.ts @@ -1,4 +1,5 @@ import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' import { Cron, CronExpression } from '@nestjs/schedule' import COS from 'cos-nodejs-sdk-v5' import dayjs from 'dayjs' @@ -8,6 +9,7 @@ import mkdirp from 'mkdirp' import { InjectModel } from 'nestjs-typegoose' import { CronDescription } from '~/common/decorator/cron-description.decorator' import { RedisItems, RedisKeys } from '~/constants/cache.constant' +import { EventBusEvents } from '~/constants/event.constant' import { LOCAL_BOT_LIST_DATA_FILE_PATH, TEMP_DIR, @@ -236,6 +238,7 @@ export class CronService { */ @Cron(CronExpression.EVERY_DAY_AT_NOON, { name: 'pushToAlgoliaSearch' }) @CronDescription('推送到 Algolia Search') + @OnEvent(EventBusEvents.PushSearch) async pushToAlgoliaSearch() { const configs = await this.configs.waitForConfigReady() if (!configs.algoliaSearchOptions.enable) { diff --git a/src/processors/helper/helper.email.service.ts b/src/processors/helper/helper.email.service.ts index 82ccbf04..46f5ee86 100644 --- a/src/processors/helper/helper.email.service.ts +++ b/src/processors/helper/helper.email.service.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' import { render } from 'ejs' import { createTransport } from 'nodemailer' +import { EventBusEvents } from '~/constants/event.constant' import { ConfigsService } from '~/modules/configs/configs.service' import { LinkModel } from '~/modules/link/link.model' import { AssetService } from './helper.asset.service' @@ -73,6 +75,7 @@ export class EmailService { break } } + @OnEvent(EventBusEvents.EmailInit) init() { this.getConfigFromConfigService() .then((config) => { diff --git a/src/processors/helper/helper.module.ts b/src/processors/helper/helper.module.ts index 75203e2f..0e918fda 100644 --- a/src/processors/helper/helper.module.ts +++ b/src/processors/helper/helper.module.ts @@ -1,4 +1,5 @@ import { forwardRef, Global, Module, Provider } from '@nestjs/common' +import { EventEmitterModule } from '@nestjs/event-emitter' import { ScheduleModule } from '@nestjs/schedule' import { AggregateModule } from '~/modules/aggregate/aggregate.module' import { BackupModule } from '~/modules/backup/backup.module' @@ -29,6 +30,21 @@ const providers: Provider[] = [ @Module({ imports: [ ScheduleModule.forRoot(), + EventEmitterModule.forRoot({ + wildcard: false, + // the delimiter used to segment namespaces + delimiter: '.', + // set this to `true` if you want to emit the newListener event + newListener: false, + // set this to `true` if you want to emit the removeListener event + removeListener: false, + // the maximum amount of listeners that can be assigned to an event + maxListeners: 10, + // show event name in memory leak message when more than maximum amount of listeners is assigned + verboseMemoryLeak: isDev, + // disable throwing uncaughtException if an error event is emitted and it has no listeners + ignoreErrors: false, + }), forwardRef(() => AggregateModule), forwardRef(() => PostModule), diff --git a/test/helper/redis-mock.helper.ts b/test/helper/redis-mock.helper.ts new file mode 100644 index 00000000..ecc5aca7 --- /dev/null +++ b/test/helper/redis-mock.helper.ts @@ -0,0 +1,53 @@ +import IORedis from 'ioredis' +import RedisMemoryServer from 'redis-memory-server' +import { CacheKeys } from '~/constants/cache.constant' + +export class MockCacheService { + private client: IORedis.Redis + constructor(port: number, host: string) { + this.client = new IORedis(port, host) + } + + private get redisClient() { + return this.client + } + + public get(key) { + return this.client.get(key) + } + + public set(key, value: any) { + return this.client.set(key, value) + } + + public getClient() { + return this.redisClient + } + + public clearAggregateCache() { + return Promise.all([ + this.redisClient.del(CacheKeys.RSS), + this.redisClient.del(CacheKeys.RSSXmlCatch), + this.redisClient.del(CacheKeys.AggregateCatch), + this.redisClient.del(CacheKeys.SiteMapCatch), + this.redisClient.del(CacheKeys.SiteMapXmlCatch), + ]) + } +} + +export const createMockRedis = async () => { + const redisServer = new RedisMemoryServer() + + const redisHost = await redisServer.getHost() + const redisPort = await redisServer.getPort() + + const service = new MockCacheService(redisPort, redisHost) + + return { + service, + async close() { + await service.getClient().quit() + await redisServer.stop() + }, + } +} diff --git a/test/src/modules/configs/configs.service.spec.ts b/test/src/modules/configs/configs.service.spec.ts new file mode 100644 index 00000000..a84749f3 --- /dev/null +++ b/test/src/modules/configs/configs.service.spec.ts @@ -0,0 +1,132 @@ +import { BadRequestException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { getModelForClass } from '@typegoose/typegoose' +import { getModelToken } from 'nestjs-typegoose' +import { dbHelper } from 'test/helper/db-mock.helper' +import { + createMockRedis, + MockCacheService, +} from 'test/helper/redis-mock.helper' +import { RedisKeys } from '~/constants/cache.constant' +import { OptionModel } from '~/modules/configs/configs.model' +import { ConfigsService } from '~/modules/configs/configs.service' +import { UserService } from '~/modules/user/user.service' +import { CacheService } from '~/processors/cache/cache.service' +import { getRedisKey } from '~/utils/redis.util' + +describe('Test ConfigsService', () => { + let service: ConfigsService + let closeRedis: any + let redisService: MockCacheService + afterAll(async () => { + await dbHelper.clear() + await dbHelper.close() + await closeRedis() + }) + const optionModel = getModelForClass(OptionModel) + const mockEmitFn = jest.fn() + beforeAll(async () => { + const { service: redisService$, close } = await createMockRedis() + closeRedis = close + redisService = redisService$ + await dbHelper.connect() + + const moduleRef = await Test.createTestingModule({ + imports: [], + providers: [ + ConfigsService, + { + provide: getModelToken(OptionModel.name), + useValue: optionModel, + }, + { provide: UserService, useValue: {} }, + { + provide: CacheService, + useValue: redisService$, + }, + { provide: EventEmitter2, useValue: { emit: mockEmitFn } }, + ], + }).compile() + + service = moduleRef.get(ConfigsService) + }) + + test('first get config should equal default config', async () => { + const config = await service.getConfig() + + expect(config).toBeDefined() + expect(config).toStrictEqual(service.defaultConfig) + }) + + describe('patch config should apply change between db and redis', () => { + it('should update config', async () => { + const updated = await service.patch('seo', { + keywords: ['foo', 'bar'], + }) + expect(updated).toBeDefined() + expect(updated).toStrictEqual({ + ...service.defaultConfig.seo, + keywords: ['foo', 'bar'], + }) + }) + + it('should update redis', async () => { + const redis = redisService.getClient() + const dataStr = await redis.get(getRedisKey(RedisKeys.ConfigCache)) + const data = JSON.parse(dataStr) + expect(data).toBeDefined() + expect(data.seo.keywords).toStrictEqual(['foo', 'bar']) + }) + + it('should update db', async () => { + const seo = (await optionModel.findOne({ name: 'seo' })).value + expect(seo).toBeDefined() + expect(seo.keywords).toStrictEqual(['foo', 'bar']) + }) + + it('should get updated config via `get()`', async () => { + const seo = await service.get('seo') + expect(seo).toBeDefined() + expect(seo.keywords).toStrictEqual(['foo', 'bar']) + }) + }) + + it('should throw error if set a wrong type of config value', async () => { + await expect( + service.patchAndValid('seo', { title: true } as any), + ).rejects.toThrow(BadRequestException) + }) + + it('should emit event if enable email option and update search', async () => { + await service.patchAndValid('mailOptions', { enable: true }) + expect(mockEmitFn).toBeCalledTimes(1) + mockEmitFn.mockClear() + + await service.patchAndValid('mailOptions', { pass: '*' }) + expect(mockEmitFn).toBeCalledTimes(1) + mockEmitFn.mockClear() + + await service.patchAndValid('mailOptions', { pass: '*', enable: false }) + expect(mockEmitFn).toBeCalledTimes(0) + mockEmitFn.mockClear() + + await service.patchAndValid('algoliaSearchOptions', { + enable: true, + }) + expect(mockEmitFn).toBeCalledTimes(1) + mockEmitFn.mockClear() + + await service.patchAndValid('algoliaSearchOptions', { + indexName: 'x', + }) + expect(mockEmitFn).toBeCalledTimes(1) + mockEmitFn.mockClear() + + await service.patchAndValid('algoliaSearchOptions', { + enable: false, + }) + expect(mockEmitFn).toBeCalledTimes(0) + mockEmitFn.mockClear() + }) +}) diff --git a/test/src/modules/snippet/snippet.controller.e2e-spec.ts b/test/src/modules/snippet/snippet.controller.e2e-spec.ts index 03ff0dde..65ab02cf 100644 --- a/test/src/modules/snippet/snippet.controller.e2e-spec.ts +++ b/test/src/modules/snippet/snippet.controller.e2e-spec.ts @@ -2,19 +2,24 @@ import { NestFastifyApplication } from '@nestjs/platform-fastify' import { Test } from '@nestjs/testing' import { getModelForClass } from '@typegoose/typegoose' import { getModelToken } from 'nestjs-typegoose' +import { dbHelper } from 'test/helper/db-mock.helper' import { setupE2EApp } from 'test/helper/register-app.helper' -import { firstKeyOfMap } from 'test/helper/utils.helper' import { SnippetController } from '~/modules/snippet/snippet.controller' import { SnippetModel, SnippetType } from '~/modules/snippet/snippet.model' import { SnippetService } from '~/modules/snippet/snippet.service' -const mockingoose = require('mockingoose') - -describe.only('test /snippets', () => { +describe('test /snippets', () => { let app: NestFastifyApplication - const model = getModelForClass(SnippetModel) - const mockTable = new Map() + beforeAll(async () => { + await dbHelper.connect() + }) + + afterAll(async () => { + await dbHelper.clear() + await dbHelper.close() + }) + const model = getModelForClass(SnippetModel) const mockPayload1: Partial = Object.freeze({ name: 'Snippet_1', @@ -38,27 +43,6 @@ describe.only('test /snippets', () => { app = await setupE2EApp(ref) }) - beforeEach(() => { - mockingoose(model).toReturn( - { - ...mockPayload1, - _id: '61dfc5e1db3c871756fa5f9c', - }, - 'findOne', - ) - mockingoose(model).toReturn( - { - ...mockPayload1, - _id: '61dfc5e1db3c871756fa5f9c', - }, - 'countDocuments', - ) - mockTable.set('61dfc5e1db3c871756fa5f9c', { - ...mockPayload1, - _id: '121212', - }) - }) - test('POST /snippets, should 422 with wrong name', async () => { await app .inject({ @@ -76,6 +60,19 @@ describe.only('test /snippets', () => { expect(res.statusCode).toBe(422) }) }) + let id: string + test('POST /snippets, should create successfully', async () => { + const res = await app.inject({ + method: 'POST', + url: '/snippets', + payload: mockPayload1, + }) + expect(res.statusCode).toBe(201) + const data = await res.json() + expect(data.name).toEqual(mockPayload1.name) + expect(data.id).toBeDefined() + id = data.id + }) test('POST /snippets, re-create same of name should return 400', async () => { await app @@ -98,7 +95,7 @@ describe.only('test /snippets', () => { await app .inject({ method: 'GET', - url: '/snippets/' + firstKeyOfMap(mockTable), + url: '/snippets/' + id, }) .then((res) => { const json = res.json() diff --git a/test/src/modules/snippet/snippet.service.spec.ts b/test/src/modules/snippet/snippet.service.spec.ts index a69fee34..eb5d4d07 100644 --- a/test/src/modules/snippet/snippet.service.spec.ts +++ b/test/src/modules/snippet/snippet.service.spec.ts @@ -6,7 +6,7 @@ import { dbHelper } from 'test/helper/db-mock.helper' import { SnippetModel, SnippetType } from '~/modules/snippet/snippet.model' import { SnippetService } from '~/modules/snippet/snippet.service' -describe.only('test Snippet Service', () => { +describe('test Snippet Service', () => { let service: SnippetService beforeAll(async () => {