refactor: config service

This commit is contained in:
Innei
2022-01-15 22:15:28 +08:00
parent 286a82b3d7
commit 70da42f8b8
16 changed files with 420 additions and 142 deletions

View File

@@ -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": "*",

112
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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`)

View File

@@ -0,0 +1,4 @@
export enum EventBusEvents {
EmailInit = 'email.init',
PushSearch = 'search.push',
}

View File

@@ -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<string, any> = 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<typeof OptionModel>,
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<T extends keyof IConfig>(key: T, data: IConfig[T]) {
public async patch<T extends keyof IConfig>(
key: T,
data: Partial<IConfig[T]>,
): Promise<IConfig[T]> {
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<T extends keyof IConfig>(
key: T,
value: Partial<IConfig[T]>,
) {
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<T extends object>(dto: ClassConstructor<T>, 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<

View File

@@ -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)
}
}

View File

@@ -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')

View File

@@ -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 {}

View File

@@ -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<string, any> = 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<T extends keyof IConfig>(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<T extends object>(dto: ClassConstructor<T>, 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
}
}

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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<any>[] = [
@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),

View File

@@ -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()
},
}
}

View File

@@ -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>(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()
})
})

View File

@@ -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<SnippetModel> = 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()

View File

@@ -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 () => {