Compare commits

...
10 Commits
Author SHA1 Message Date
shuijiangui fac8be4dd0 add mq and redis 2025-08-01 23:08:40 +08:00
shuijiangui de01ffd7c7 add mq and redis 2025-08-01 10:50:03 +08:00
shuijiangui 91b5cce39b add mq and redis 2025-08-01 10:49:50 +08:00
shuijiangui ef6380395c add mq and redis 2025-08-01 10:49:23 +08:00
shuijiangui 24cb8e9843 add mq and redis 2025-07-04 16:24:32 +08:00
shuijiangui 730b77448e modify bugs 2025-07-04 10:26:05 +08:00
shuijiangui 0d11d2bd8a add bullmq and redis 2025-07-03 22:26:18 +08:00
shuijiangui 769973517b add test case 2025-07-02 22:37:06 +08:00
shuijiangui d2692e6a11 modify bugs 2025-07-02 22:35:23 +08:00
shuijiangui 0f8abaec7f add rbac module 2025-07-01 15:33:31 +08:00
64 changed files with 8563 additions and 1418 deletions
+8
View File
@@ -25,6 +25,7 @@
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.3", "@casl/ability": "^6.7.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.3", "@nestjs/common": "^11.1.3",
"@nestjs/core": "^11.1.3", "@nestjs/core": "^11.1.3",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
@@ -33,6 +34,7 @@
"@nestjs/swagger": "^11.2.0", "@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.56.0",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -40,13 +42,16 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"email-templates": "^12.0.3",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"find-up": "^7.0.0", "find-up": "^7.0.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"meilisearch": "^0.51.0", "meilisearch": "^0.51.0",
"mysql2": "^3.14.1", "mysql2": "^3.14.1",
"nodemailer": "^7.0.4",
"ora": "^8.2.0", "ora": "^8.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -56,6 +61,7 @@
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"tencentcloud-sdk-nodejs": "^4.1.67",
"typeorm": "^0.3.24", "typeorm": "^0.3.24",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"validator": "^13.15.15", "validator": "^13.15.15",
@@ -73,12 +79,14 @@
"@swc/cli": "^0.7.7", "@swc/cli": "^0.7.7",
"@swc/core": "^1.12.1", "@swc/core": "^1.12.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/email-templates": "^10.0.4",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.17", "@types/lodash": "^4.17.17",
"@types/node": "^24.0.1", "@types/node": "^24.0.1",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
+1719 -5
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -8,10 +8,7 @@ export const database = createDBConfig((configure) => ({
common: { common: {
synchronize: true, synchronize: true,
// 启用详细日志以便调试 SQL 错误 // 启用详细日志以便调试 SQL 错误
logging: logging: configure.env.get('NODE_ENV') === 'development' ? ['error', 'query'] : ['error'],
configure.env.get('NODE_ENV') === 'development'
? ['query', 'error', 'schema', 'warn', 'info', 'log']
: ['error'],
// 启用最大日志记录 // 启用最大日志记录
maxQueryExecutionTime: 1000, maxQueryExecutionTime: 1000,
}, },
+4
View File
@@ -3,3 +3,7 @@ export * from './content.config';
export * from './app.config'; export * from './app.config';
export * from './meili.config'; export * from './meili.config';
export * from './api.config'; export * from './api.config';
export * from './sms.config';
export * from './smtp.config';
export * from './redis.config';
export * from './queue.config';
+1 -1
View File
@@ -1,4 +1,4 @@
import { createMeiliConfig } from '../modules/meilisearch/config'; import { createMeiliConfig } from '@/modules/meilisearch/config';
export const meili = createMeiliConfig((configure) => [ export const meili = createMeiliConfig((configure) => [
{ {
+5
View File
@@ -0,0 +1,5 @@
import { QueueOptions } from '@/modules/core/types';
export const queue: () => QueueOptions = () => ({
redis: 'default',
});
+7
View File
@@ -0,0 +1,7 @@
import { RedisOptions } from '@/modules/core/types';
export const redis: () => RedisOptions = () => ({
host: '192.168.50.137',
port: 6379,
password: '123456&Qw',
});
+10
View File
@@ -0,0 +1,10 @@
import { Configure } from '@/modules/config/configure';
import { SmsOptions } from '@/modules/core/types';
export const sms: (configure: Configure) => SmsOptions = (configure) => ({
sign: configure.env.get('SMS_CLOUD_SING', 'liuyi'),
region: configure.env.get('SMS_CLOUD_REGION', 'ap-guangzhou'),
appid: configure.env.get('SMS_CLOUD_APPID', 'app-id'),
secretId: configure.env.get('SMS_CLOUD_ID', 'your-secret-id'),
secretKey: configure.env.get('SMS_CLOUD_KEY', 'your-secret-key'),
});
+15
View File
@@ -0,0 +1,15 @@
import path from 'path';
import { Configure } from '@/modules/config/configure';
import { SmtpOptions } from '@/modules/core/types';
export const smtp: (configure: Configure) => SmtpOptions = (configure) => ({
host: configure.env.get('SMTP_HOST', 'localhost'),
user: configure.env.get('SMTP_USER', 'test'),
password: configure.env.get('SMTP_PASSWORD', ''),
from: configure.env.get('SMTP_FROM', '平克小站<support@localhost>'),
port: configure.env.get('SMTP_PORT', (v) => Number(v), 25),
secure: configure.env.get('SMTP_SSL', (v) => JSON.parse(v), false),
// Email模板路径
resource: path.resolve(__dirname, '../../assets/emails'),
});
+3 -1
View File
@@ -1,3 +1,5 @@
import { createUserConfig } from '@/modules/user/config'; import { createUserConfig } from '@/modules/user/config';
export const user = createUserConfig(() => ({})); export const user = createUserConfig(() => ({
hash: 10,
}));
+1 -1
View File
@@ -44,8 +44,8 @@ export class ContentModule {
categoryRepository: repositories.CategoryRepository, categoryRepository: repositories.CategoryRepository,
tagRepository: repositories.TagRepository, tagRepository: repositories.TagRepository,
categoryService: services.CategoryService, categoryService: services.CategoryService,
searchService: SearchService,
userRepository: UserRepository, userRepository: UserRepository,
searchService: SearchService,
) { ) {
return new PostService( return new PostService(
postRepository, postRepository,
@@ -104,7 +104,7 @@ export class PostController {
@SerializeOptions({ groups: ['post-detail'] }) @SerializeOptions({ groups: ['post-detail'] })
async show(@Param('id', new ParseUUIDPipe()) id: string) { async show(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.detail(id, async (qb) => return this.postService.detail(id, async (qb) =>
qb.andWhere({ publishedAt: Not(IsNull()), deletedAt: Not(IsNull()) }), qb.andWhere({ publishedAt: Not(IsNull()), deletedAt: IsNull() }),
); );
} }
+3 -1
View File
@@ -1,4 +1,4 @@
import { IsDefined, IsUUID } from 'class-validator'; import { ArrayMinSize, IsArray, IsDefined, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
@@ -6,5 +6,7 @@ import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator
export class DeleteDto { export class DeleteDto {
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' }) @IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
@IsDefined({ each: true, message: 'The ID must be specified' }) @IsDefined({ each: true, message: 'The ID must be specified' })
@IsArray()
@ArrayMinSize(1)
ids: string[]; ids: string[];
} }
+1 -2
View File
@@ -39,7 +39,6 @@ export class PostEntity extends BaseEntity {
@Column({ comment: '文章描述', nullable: true }) @Column({ comment: '文章描述', nullable: true })
summary?: string; summary?: string;
@Expose()
@Expose() @Expose()
@Column({ comment: '关键字', type: 'simple-array', nullable: true }) @Column({ comment: '关键字', type: 'simple-array', nullable: true })
keywords?: string[]; keywords?: string[];
@@ -69,7 +68,7 @@ export class PostEntity extends BaseEntity {
@Expose() @Expose()
@Type(() => Date) @Type(() => Date)
@DeleteDateColumn({ comment: '删除时间' }) @DeleteDateColumn({ comment: '删除时间' })
deleteAt: Date; deletedAt: Date;
@Expose() @Expose()
commentCount: number; commentCount: number;
+23
View File
@@ -3,7 +3,9 @@ import { ModuleRef } from '@nestjs/core';
import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities'; import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities';
import { PermissionAction, SystemRoles } from '@/modules/rbac/constants'; import { PermissionAction, SystemRoles } from '@/modules/rbac/constants';
import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities';
import { RbacResolver } from '@/modules/rbac/rbac.resolver'; import { RbacResolver } from '@/modules/rbac/rbac.resolver';
import { UserEntity } from '@/modules/user/entities';
@Injectable() @Injectable()
export class ContentRbac implements OnModuleInit { export class ContentRbac implements OnModuleInit {
@@ -73,6 +75,27 @@ export class ContentRbac implements OnModuleInit {
subject: CommentEntity, subject: CommentEntity,
}, },
}, },
{
name: 'permission.manage',
rule: {
action: PermissionAction.MANAGE,
subject: PermissionEntity,
},
},
{
name: 'role.manage',
rule: {
action: PermissionAction.MANAGE,
subject: RoleEntity,
},
},
{
name: 'user.manage',
rule: {
action: PermissionAction.MANAGE,
subject: UserEntity,
},
},
]); ]);
resolver.addRoles([ resolver.addRoles([
@@ -11,12 +11,13 @@ export class PostRepository extends BaseRepository<PostEntity> {
return this.createQueryBuilder(this.qbName) return this.createQueryBuilder(this.qbName)
.leftJoinAndSelect(`${this.qbName}.category`, 'category') .leftJoinAndSelect(`${this.qbName}.category`, 'category')
.leftJoinAndSelect(`${this.qbName}.tags`, 'tags') .leftJoinAndSelect(`${this.qbName}.tags`, 'tags')
.leftJoinAndSelect(`${this.qbName}.author`, 'author')
.addSelect((query) => { .addSelect((query) => {
return query return query
.select('COUNT(c.id)', 'count') .select('COUNT(c.id)', 'count')
.from(CommentEntity, 'c') .from(CommentEntity, 'c')
.where(`c.post.id = ${this.qbName}.id`); .where(`c.post.id = ${this.qbName}.id`);
}, 'commentCount') }, 'commentCount')
.loadRelationCountAndMap(`${this.qbName}.commentCOunt`, `${this.qbName}.comments`); .loadRelationCountAndMap(`${this.qbName}.commentCount`, `${this.qbName}.comments`);
} }
} }
+8 -5
View File
@@ -147,8 +147,8 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
.getMany(); .getMany();
let result: PostEntity[]; let result: PostEntity[];
if (trash) { if (trash) {
const directs = items.filter((item) => !isNil(item.deleteAt)); const directs = items.filter((item) => !isNil(item.deletedAt));
const softs = items.filter((item) => isNil(item.deleteAt)); const softs = items.filter((item) => isNil(item.deletedAt));
result = [ result = [
...(await this.repository.remove(directs)), ...(await this.repository.remove(directs)),
...(await this.repository.softRemove(softs)), ...(await this.repository.softRemove(softs)),
@@ -172,7 +172,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
.where('post.id IN (:...ids)', { ids }) .where('post.id IN (:...ids)', { ids })
.withDeleted() .withDeleted()
.getMany(); .getMany();
const trashes = items.filter((item) => !isNil(item.deleteAt)); const trashes = items.filter((item) => !isNil(item.deletedAt));
await this.searchService.update(trashes); await this.searchService.update(trashes);
const trashedIds = trashes.map((item) => item.id); const trashedIds = trashes.map((item) => item.id);
if (trashedIds.length < 1) { if (trashedIds.length < 1) {
@@ -190,7 +190,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
options: FindParams, options: FindParams,
callback?: QueryHook<PostEntity>, callback?: QueryHook<PostEntity>,
) { ) {
const { orderBy, isPublished, category, tag, trashed, search } = options; const { orderBy, isPublished, category, tag, trashed, search, author } = options;
if (typeof isPublished === 'boolean') { if (typeof isPublished === 'boolean') {
isPublished isPublished
? qb.where({ publishedAt: Not(IsNull()) }) ? qb.where({ publishedAt: Not(IsNull()) })
@@ -212,6 +212,9 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
if (tag) { if (tag) {
qb.where('tags.id = :id', { id: tag }); qb.where('tags.id = :id', { id: tag });
} }
if (author) {
qb.where('author.id = :id', { id: author });
}
if (callback) { if (callback) {
return callback(qb); return callback(qb);
} }
@@ -252,6 +255,6 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
const tree = await this.categoryRepository.findDescendantsTree(root); const tree = await this.categoryRepository.findDescendantsTree(root);
const flatDes = await this.categoryRepository.toFlatTrees(tree.children); const flatDes = await this.categoryRepository.toFlatTrees(tree.children);
const ids = [tree.id, ...flatDes.map((item) => item.id)]; const ids = [tree.id, ...flatDes.map((item) => item.id)];
return qb.where('categoryRepository.id IN (:...ids)', { ids }); return qb.where('category.id IN (:...ids)', { ids });
} }
} }
@@ -48,7 +48,7 @@ export class SearchService implements OnModuleInit {
} }
async search(text: string, param: SearchOption = {}): Promise<any> { async search(text: string, param: SearchOption = {}): Promise<any> {
const option = { page: 1, limit: 10, trashed: SelectTrashMode.ONLY, ...param }; const option = { page: 1, limit: 10, trashed: SelectTrashMode.NONE, ...param };
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit; const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
const page = isNil(option.page) || option.page < 1 ? 1 : option.page; const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
let filter = ['deletedAt IS NULL']; let filter = ['deletedAt IS NULL'];
@@ -64,7 +64,7 @@ export class SearchService implements OnModuleInit {
.index(this.index) .index(this.index)
.search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter }); .search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter });
return { return {
item: result.hits, items: result.hits,
currentPage: result.page, currentPage: result.page,
perPage: result.hitsPerPage, perPage: result.hitsPerPage,
totalItems: result.estimatedTotalHits, totalItems: result.estimatedTotalHits,
+1 -1
View File
@@ -29,7 +29,7 @@ export async function getSearchItem(
'body', 'body',
'summary', 'summary',
'commentCount', 'commentCount',
'deleteAt', 'deletedAt',
'publishedAt', 'publishedAt',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
+62 -3
View File
@@ -1,4 +1,18 @@
import { DynamicModule, Module } from '@nestjs/common'; import { BullModule } from '@nestjs/bullmq';
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { isArray, isNil, omit } from 'lodash';
import { RedisService, SmsService, SmtpService } from '@/modules/core/services';
import type {
QueueOptions,
RedisOption,
RedisOptions,
SmsOptions,
SmtpOptions,
} from '@/modules/core/types';
import { createQueueOptions, createRedisOptions } from '@/options';
import { Configure } from '../config/configure'; import { Configure } from '../config/configure';
@@ -6,11 +20,56 @@ import { Configure } from '../config/configure';
export class CoreModule { export class CoreModule {
static async forRoot(configure: Configure): Promise<DynamicModule> { static async forRoot(configure: Configure): Promise<DynamicModule> {
await configure.store('app.name'); await configure.store('app.name');
const providers: ModuleMetadata['providers'] = [];
const exports: ModuleMetadata['exports'] = [];
let imports: ModuleMetadata['imports'] = [];
const redis: RedisOption[] | undefined = createRedisOptions(
await configure.get<RedisOptions>('redis'),
);
if (!isNil(redis)) {
providers.push({
provide: RedisService,
useFactory: () => {
const service = new RedisService(redis);
service.createClients();
return service;
},
});
exports.push(RedisService);
const queues = createQueueOptions(await configure.get<QueueOptions>('queue'), redis);
if (!isNil(queues)) {
if (isArray(queues)) {
imports = queues.map((v) => BullModule.forRoot(v.name, omit(v, 'name')));
} else {
imports.push(BullModule.forRoot(queues));
}
}
}
const sms = await configure.get<SmsOptions>('sms');
if (!isNil(sms)) {
providers.push({
provide: SmsService,
useFactory: () => new SmsService(sms),
});
exports.push(SmsService);
}
const smtp = await configure.get<SmtpOptions>('smtp');
if (!isNil(smtp)) {
providers.push({
provide: SmtpService,
useFactory: () => new SmtpService(smtp),
});
exports.push(SmtpService);
}
return { return {
module: CoreModule, module: CoreModule,
global: true, global: true,
providers: [], providers,
exports: [], exports,
imports,
}; };
} }
} }
@@ -1,11 +1,11 @@
import { Paramtype, SetMetadata } from '@nestjs/common'; import { Paramtype, SetMetadata } from '@nestjs/common';
import { ClassTransformOptions } from 'class-transformer'; import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
import { ValidationOptions } from 'class-validator'; import { ValidatorOptions } from '@nestjs/common/interfaces/external/validator-options.interface';
import { DTO_VALIDATION_OPTIONS } from '../contants'; import { DTO_VALIDATION_OPTIONS } from '../contants';
export const DtoValidation = ( export const DtoValidation = (
options?: ValidationOptions & { transformOptions?: ClassTransformOptions } & { options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & {
type?: Paramtype; type?: Paramtype;
}, },
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {}); ) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});
+3
View File
@@ -0,0 +1,3 @@
export * from './redis.service';
export * from './sms.service';
export * from './smtp.service';
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { isNil } from 'lodash';
import { RedisOption } from '@/modules/core/types';
@Injectable()
export class RedisService {
protected options: RedisOption[];
protected clients: Record<string, Redis> = {};
constructor(protected _options: RedisOption[]) {
this.options = _options;
}
async createClients() {
this.options.map(async (option) => {
this.clients[option.name] = new Redis(option);
});
}
getClient(name?: string): Redis {
const key = isNil(name) ? 'default' : name;
if (this.clients[key]) {
return this.clients[key];
}
throw new Error(`Unknown client ${key}`);
}
getClients() {
return this.clients;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
import { deepMerge } from '@/modules/core/helpers';
import { SmsOptions, SmsSendParams } from '@/modules/core/types';
const SmsClient = tencentcloud.sms.v20210111.Client;
/**
* 腾讯云短信驱动
*/
@Injectable()
export class SmsService {
/**
* 初始化配置
* @param options 短信发送选项
*/
constructor(protected options: SmsOptions) {}
/**
* 创建短信发送驱动实例
* @param options 驱动选项
*/
protected makeClient(options: SmsOptions) {
const { secretId, secretKey, region, endpoint } = options;
return new SmsClient({
credential: { secretId, secretKey },
region,
profile: {
httpProfile: { endpoint: endpoint ?? 'sms.tencentcloudapi.com' },
},
});
}
/**
* 转义通用发送参数为腾讯云短信服务发送参数
* @param params 发送参数
* @param options 驱动选项
*/
protected transSendParams(params: SmsSendParams, options: SmsOptions) {
const { numbers, template, vars, appid, sign, ...others } = params;
let paramSet: RecordAny = {};
if (vars) {
paramSet = Object.fromEntries(
Object.entries(vars).map(([key, value]) => [key, value.toString()]),
);
}
return {
PhoneNumberSet: numbers.map((n) => {
const phone: string[] = n.split('.');
return `${phone[0]}${phone[1]}`;
}),
TemplateId: template,
SmsSdkAppId: appid ?? options.appid,
SignName: sign ?? options.sign,
TemplateParamSet: Object.values(paramSet),
...(others ?? {}),
};
}
/**
* 合并配置并发送短信
* @param params 短信发送参数
* @param options 自定义驱动选项(可用于临时覆盖默认选项)
*/
async send<T>(params: SmsSendParams & T, options?: SmsSendParams) {
const newOptions = deepMerge(this.options, options ?? {}) as SmsOptions;
const client = this.makeClient(newOptions);
return client.SendSms(this.transSendParams(params, newOptions));
}
}
+93
View File
@@ -0,0 +1,93 @@
import path from 'path';
import { Injectable } from '@nestjs/common';
import Email from 'email-templates';
import { pick } from 'lodash';
import mailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer';
import SMTPConnection from 'nodemailer/lib/smtp-connection';
import { deepMerge } from '@/modules/core/helpers';
import { SmtpOptions, SmtpSendParams } from '@/modules/core/types';
/**
* SMTP邮件发送驱动
*/
@Injectable()
export class SmtpService {
/**
* 初始化配置
* @param options
*/
constructor(protected readonly options: SmtpOptions) {}
/**
* 合并配置并发送邮件
* @param params
* @param options
*/
async send<T>(params: SmtpSendParams & T, options?: SmtpOptions) {
const newOptions = deepMerge(this.options, options ?? {}) as SmtpOptions;
const client = this.makeClient(newOptions);
return this.makeSend(client, params, newOptions);
}
/**
* 创建NodeMailer客户端
* @param options
*/
protected makeClient(options: SmtpOptions) {
const { host, secure, user, password, port } = options;
const clientOptions: SMTPConnection.Options = {
host,
secure: secure ?? false,
auth: {
user,
pass: password,
},
};
if (!clientOptions.secure) {
clientOptions.port = port ?? 25;
}
return mailer.createTransport(clientOptions);
}
/**
* 转义通用发送参数为NodeMailer发送参数
* @param client
* @param params
* @param options
*/
protected async makeSend(client: Mail, params: SmtpSendParams, options: SmtpOptions) {
const tplPath = path.resolve(options.resource, params.name ?? 'custom');
const textOnly = !params.html && params.text;
const noHtmlToText = params.html && params.text;
const configd: Email.EmailConfig = {
preview: params.preview ?? false,
send: !params.preview,
message: { from: params.from ?? options.from ?? options.user },
transport: client,
subjectPrefix: params.subjectPrefix,
textOnly,
juiceResources: {
preserveImportant: true,
webResources: {
relativeTo: tplPath,
},
},
};
if (noHtmlToText) {
configd.htmlToText = false;
}
const email = new Email(configd);
const message = {
...pick(params, ['from', 'to', 'reply', 'attachments', 'subject']),
locals: params.vars,
};
return email.send({
template: tplPath,
message,
});
}
}
+114
View File
@@ -3,7 +3,12 @@ import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
import { IAuthGuard } from '@nestjs/passport'; import { IAuthGuard } from '@nestjs/passport';
import { NestFastifyApplication } from '@nestjs/platform-fastify'; import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { QueueOptions as BullMQOptions } from 'bullmq';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import Email from 'email-templates';
import { RedisOptions as IORedisOptions } from 'ioredis';
import { Attachment } from 'nodemailer/lib/mailer';
import { Ora } from 'ora'; import { Ora } from 'ora';
import { StartOptions } from 'pm2'; import { StartOptions } from 'pm2';
import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
@@ -142,3 +147,112 @@ export interface DynamicRelation {
| ReturnType<typeof ManyToMany>; | ReturnType<typeof ManyToMany>;
column: string; column: string;
} }
/**
* 嵌套对象
*/
export type NestedRecord = Record<string, RecordAny>;
/**
* core模块参数选项
*/
export interface CoreOptions {
database?: () => TypeOrmModuleOptions;
sms?: () => SmsOptions;
smtp?: () => SmtpOptions;
redis?: () => RedisOptions;
queue?: () => QueueOptions;
}
/**
* 腾讯云短信驱动配置
*/
export type SmsOptions<T extends NestedRecord = RecordNever> = {
secretId: string;
secretKey: string;
sign: string;
appid: string;
region: string;
endpoint?: string;
} & T;
/**
* 发送接口参数
*/
export interface SmsSendParams {
appid?: string;
numbers: string[];
template: string;
sign?: string;
endpoint?: string;
vars?: Record<string, any>;
ExtendCode?: string;
SessionContext?: string;
SenderId?: string;
}
/**
* SMTP邮件发送配置
*/
export type SmtpOptions<T extends NestedRecord = RecordNever> = {
host: string;
user: string;
password: string;
// Email模板总路径
resource: string;
from?: string;
port?: number;
secure?: boolean;
} & T;
/**
* 公共发送接口配置
*/
export interface SmtpSendParams {
// 模板名称
name?: string;
// 发信地址
from?: string;
// 主题
subject?: string;
// 目标地址
to: string | string[];
// 回信地址
reply?: string;
// 是否加载html模板
html?: boolean;
// 是否加载text模板
text?: boolean;
// 模板变量
vars?: Record<string, any>;
// 是否预览
preview?: boolean | Email.PreviewEmailOpts;
// 主题前缀
subjectPrefix?: string;
// 附件
attachments?: Attachment[];
}
/**
* Redis连接配置
*/
export type RedisOption = Omit<IORedisOptions, 'name'> & { name: string };
/**
* Redis配置
*/
export type RedisOptions = IORedisOptions | Array<RedisOption>;
/**
* 队列项配置
*/
export type QueueOption = Omit<BullMQOptions, 'connection'> & { redis?: string };
/**
* 队列配置
*/
export type QueueOptions = QueueOption | Array<{ name: string } & QueueOption>;
/**
* BullMQ模块注册配置
*/
export type BullOptions = BullMQOptions | Array<{ name: string } & BullMQOptions>;
+1 -1
View File
@@ -83,7 +83,7 @@ export abstract class BaseService<
async detail(id: string, callback?: QueryHook<T>) { async detail(id: string, callback?: QueryHook<T>) {
const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback); const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback);
const item = qb.getOne(); const item = await qb.getOne();
if (!item) { if (!item) {
throw new NotFoundException(`${this.repository.qbName} ${id} NOT FOUND`); throw new NotFoundException(`${this.repository.qbName} ${id} NOT FOUND`);
} }
@@ -20,6 +20,9 @@ export class UniqueConstraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {} constructor(private dataSource: DataSource) {}
async validate(value: any, validationArguments?: ValidationArguments): Promise<boolean> { async validate(value: any, validationArguments?: ValidationArguments): Promise<boolean> {
if (isNil(value)) {
return true;
}
const config: Omit<Condition, 'entity'> = { property: validationArguments.property }; const config: Omit<Condition, 'entity'> = { property: validationArguments.property };
const condition = ('entity' in validationArguments.constraints[0] const condition = ('entity' in validationArguments.constraints[0]
? merge(config, validationArguments.constraints[0]) ? merge(config, validationArguments.constraints[0])
@@ -31,7 +31,7 @@ export const ContentFactory = defineFactory(
post.publishedAt = (await getTime(configure)).toDate(); post.publishedAt = (await getTime(configure)).toDate();
} }
if (Math.random() > 0.5) { if (Math.random() > 0.5) {
post.deleteAt = (await getTime(configure)).toDate(); post.deletedAt = (await getTime(configure)).toDate();
} }
if (category) { if (category) {
post.category = category; post.category = category;
@@ -20,9 +20,6 @@ const permission: PermissionChecker = async (ab) =>
export class PermissionController { export class PermissionController {
constructor(private service: PermissionService) {} constructor(private service: PermissionService) {}
permission: PermissionChecker = async (ab) =>
ab.can(PermissionAction.MANAGE, PermissionEntity.name);
/** /**
* 分页列表查询 * 分页列表查询
* @param options * @param options
+3
View File
@@ -30,18 +30,21 @@ export class RoleEntity extends BaseEntity {
/** /**
* 角色名称 * 角色名称
*/ */
@Expose()
@Column({ comment: '角色名称' }) @Column({ comment: '角色名称' })
name: string; name: string;
/** /**
* 显示名称 * 显示名称
*/ */
@Expose()
@Column({ comment: '显示名称', nullable: true }) @Column({ comment: '显示名称', nullable: true })
label?: string; label?: string;
/** /**
* 角色描述 * 角色描述
*/ */
@Expose({ groups: ['role-detail'] })
@Column({ comment: '角色描述', nullable: true, type: 'text' }) @Column({ comment: '角色描述', nullable: true, type: 'text' })
description?: string; description?: string;
+5 -4
View File
@@ -1,27 +1,28 @@
import { createMongoAbility } from '@casl/ability'; import { createMongoAbility } from '@casl/ability';
import { ExecutionContext, ForbiddenException } from '@nestjs/common'; import { ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core'; import { ModuleRef, Reflector } from '@nestjs/core';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
import { PermissionEntity } from '@/modules/rbac/entities';
import { JwtAuthGuard } from '@/modules/user/guards'; import { JwtAuthGuard } from '@/modules/user/guards';
import { UserRepository } from '@/modules/user/repositories'; import { UserRepository } from '@/modules/user/repositories';
import { TokenService } from '@/modules/user/services'; import { TokenService } from '@/modules/user/services';
import { PERMISSION_CHECKERS } from '../constants'; import { PERMISSION_CHECKERS } from '../constants';
import { PermissionEntity } from '../entities/permission.entity';
import { RbacResolver } from '../rbac.resolver'; import { RbacResolver } from '../rbac.resolver';
import { CheckerParams, PermissionChecker } from '../types'; import { CheckerParams, PermissionChecker } from '../types';
@Injectable()
export class RbacGuard extends JwtAuthGuard { export class RbacGuard extends JwtAuthGuard {
constructor( constructor(
protected reflector: Reflector, protected reflector: Reflector,
protected resolver: RbacResolver, protected resolver: RbacResolver,
protected tokenService: TokenService, protected tokenService: TokenService,
protected userRepository: UserRepository, protected userRepository: UserRepository,
protected modeleRef: ModuleRef, protected moduleRef: ModuleRef,
) { ) {
super(reflector, tokenService); super(reflector, tokenService);
} }
@@ -45,7 +46,7 @@ export class RbacGuard extends JwtAuthGuard {
resolver: this.resolver, resolver: this.resolver,
repository: this.userRepository, repository: this.userRepository,
checkers, checkers,
moduleRef: this.modeleRef, moduleRef: this.moduleRef,
request: context.switchToHttp().getRequest(), request: context.switchToHttp().getRequest(),
}); });
if (!result) { if (!result) {
+8 -4
View File
@@ -4,6 +4,8 @@ import { InternalServerErrorException, OnApplicationBootstrap } from '@nestjs/co
import { isArray, isNil, omit } from 'lodash'; import { isArray, isNil, omit } from 'lodash';
import { DataSource, EntityManager, In, Not } from 'typeorm'; import { DataSource, EntityManager, In, Not } from 'typeorm';
import { PermissionEntity } from '@/modules/rbac/entities';
import { Configure } from '../config/configure'; import { Configure } from '../config/configure';
import { deepMerge } from '../core/helpers'; import { deepMerge } from '../core/helpers';
@@ -11,7 +13,6 @@ import { deepMerge } from '../core/helpers';
import { UserEntity } from '../user/entities'; import { UserEntity } from '../user/entities';
import { SYSTEM_PERMISSION, SystemRoles } from './constants'; import { SYSTEM_PERMISSION, SystemRoles } from './constants';
import { PermissionEntity } from './entities/permission.entity';
import { RoleEntity } from './entities/role.entity'; import { RoleEntity } from './entities/role.entity';
import { PermissionType, Role } from './types'; import { PermissionType, Role } from './types';
@@ -204,9 +205,12 @@ export class RbacResolver<P extends AbilityTuple = AbilityTuple, T extends Mongo
// 同步普通角色 // 同步普通角色
for (const role of roles) { for (const role of roles) {
const rolePermissions = await manager.findBy(PermissionEntity, { const rolePermissions =
name: In(this.roles.find(({ name }) => name === role.name).permissions), isNil(role.permissions) || role.permissions.length <= 0
}); ? []
: await manager.findBy(PermissionEntity, {
name: In(role.permissions.map(({ name }) => name)),
});
await roleRepo await roleRepo
.createQueryBuilder('role') .createQueryBuilder('role')
.relation(RoleEntity, 'permissions') .relation(RoleEntity, 'permissions')
+17
View File
@@ -19,6 +19,23 @@ export function defaultUserConfig(configure: Configure): UserConfig {
3600 * 30, 3600 * 30,
), ),
}, },
captcha: {
sms: {
login: {
template: configure.env.get('SMS_LOGIN_CAPTCHA_CLOUD', 'your-id'),
},
register: {
template: configure.env.get('SMS_REGISTER_CAPTCHA_CLOUD', 'your-id'),
},
'retrieve-password': {
template: configure.env.get('SMS_RETRIEVE_PASSWORD_CAPTCHA_CLOUD', 'your-id'),
},
},
email: {
register: {},
'retrieve-password': {},
},
},
}; };
} }
+65
View File
@@ -40,3 +40,68 @@ export const TokenConst = {
}; };
export const ALLOW_GUEST = 'allowGuest'; export const ALLOW_GUEST = 'allowGuest';
/**
* 验证码发送数据DTO验证组
*/
export enum CaptchaDtoGroups {
// 发送短信登录验证码
PHONE_LOGIN = 'phone-login',
// 发送邮件登录验证码
EMAIL_LOGIN = 'email-login',
// 发送短信注册验证码
PHONE_REGISTER = 'phone-register',
// 发送邮件注册验证码
EMAIL_REGISTER = 'email-register',
// 发送找回密码的短信验证码
PHONE_RETRIEVE_PASSWORD = 'phone-retrieve-password',
// 发送找回密码的邮件验证码
EMAIL_RETRIEVE_PASSWORD = 'email-retrieve-password',
// 发送登录用户密码重置的短信验证码
PHONE_RESET_PASSWORD = 'phone-reset-password',
// 发送登录用户密码重置的邮件验证码
EMAIL_RESET_PASSWORD = 'email-reset-password',
// 发送手机号绑定或更改的短信验证码
BOUND_PHONE = 'bound-phone',
// 发送邮箱地址绑定或更改的邮件验证码
BOUND_EMAIL = 'bound-email',
}
/**
* 验证码操作类别
*/
export enum CaptchaActionType {
// 登录操作
LOGIN = 'login',
// 注册操作
REGISTER = 'register',
// 找回密码操作
RETRIEVE_PASSWORD = 'retrieve-password',
// 重置密码操作
RESET_PASSWORD = 'reset-password',
// 手机号或邮箱地址绑定操作
ACCOUNT_BOUND = 'account-bound',
}
/**
* 验证码类型
*/
export enum CaptchaType {
SMS = 'sms',
EMAIL = 'email',
}
/**
* 发送验证码异步列队名称
*/
export const SEND_CAPTCHA_QUEUE = 'send-captcha-queue';
/**
* 发送短信验证码任务处理名称
*/
export const SMS_CAPTCHA_JOB = 'sms-captcha-job';
/**
* 发送邮件验证码任务处理名称
*/
export const EMAIL_CAPTCHA_JOB = 'mail-captcha-job';
@@ -7,6 +7,7 @@ import {
Request, Request,
SerializeOptions, SerializeOptions,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@@ -14,6 +15,8 @@ import { pick } from 'lodash';
import { Depends } from '@/modules/restful/decorators/depend.decorator'; import { Depends } from '@/modules/restful/decorators/depend.decorator';
import { UserIdInterceptor } from '@/modules/user/interceptors';
import { Guest } from '../decorators/guest.decorator'; import { Guest } from '../decorators/guest.decorator';
import { RequestUser } from '../decorators/user.request.decorator'; import { RequestUser } from '../decorators/user.request.decorator';
import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto'; import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto';
@@ -83,8 +86,13 @@ export class AccountController {
*/ */
@Patch() @Patch()
@ApiBearerAuth() @ApiBearerAuth()
@UseInterceptors(UserIdInterceptor)
@SerializeOptions({ groups: ['user-detail'] }) @SerializeOptions({ groups: ['user-detail'] })
async update(@RequestUser() user: ClassToPlain<UserEntity>, @Body() data: UpdateAccountDto) { async update(
@RequestUser() user: ClassToPlain<UserEntity>,
@Body()
data: UpdateAccountDto,
) {
return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) }); return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) });
} }
@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
@@ -18,6 +19,7 @@ import { PermissionAction } from '@/modules/rbac/constants';
import { Permission } from '@/modules/rbac/decorators/permission.decorator'; import { Permission } from '@/modules/rbac/decorators/permission.decorator';
import { PermissionChecker } from '@/modules/rbac/types'; import { PermissionChecker } from '@/modules/rbac/types';
import { Depends } from '@/modules/restful/decorators/depend.decorator'; import { Depends } from '@/modules/restful/decorators/depend.decorator';
import { RequestUser } from '@/modules/user/decorators/user.request.decorator';
import { CreateUserDto, FrontedQueryUserDto, UpdateUserDto } from '@/modules/user/dtos/user.dto'; import { CreateUserDto, FrontedQueryUserDto, UpdateUserDto } from '@/modules/user/dtos/user.dto';
import { UserEntity } from '@/modules/user/entities'; import { UserEntity } from '@/modules/user/entities';
import { UserService } from '@/modules/user/services'; import { UserService } from '@/modules/user/services';
@@ -40,7 +42,7 @@ export class UserController {
@Permission(permission) @Permission(permission)
@SerializeOptions({ groups: ['user-list'] }) @SerializeOptions({ groups: ['user-list'] })
async list(@Query() options: FrontedQueryUserDto) { async list(@Query() options: FrontedQueryUserDto) {
return this.service.list(options); return this.service.paginate(options);
} }
/** /**
@@ -78,13 +80,17 @@ export class UserController {
/** /**
* 批量删除用户 * 批量删除用户
* @param user
* @param data * @param data
*/ */
@Delete() @Delete()
@Permission(permission) @Permission(permission)
@SerializeOptions({ groups: ['user-list'] }) @SerializeOptions({ groups: ['user-list'] })
async delete(@Body() data: DeleteWithTrashDto) { async delete(@RequestUser() user: UserEntity, @Body() data: DeleteWithTrashDto) {
const { ids, trash } = data; const { ids, trash } = data;
if (ids.includes(user.id)) {
throw new ForbiddenException();
}
return this.service.delete(ids, trash); return this.service.delete(ids, trash);
} }
@@ -2,7 +2,7 @@ import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { IsNull, Not } from 'typeorm'; import { IsNull } from 'typeorm';
import { SelectTrashMode } from '@/modules/database/constants'; import { SelectTrashMode } from '@/modules/database/constants';
import { Depends } from '@/modules/restful/decorators/depend.decorator'; import { Depends } from '@/modules/restful/decorators/depend.decorator';
@@ -25,7 +25,7 @@ export class UserController {
@Guest() @Guest()
@SerializeOptions({ groups: ['user-list'] }) @SerializeOptions({ groups: ['user-list'] })
async list(@Query() options: FrontedQueryUserDto) { async list(@Query() options: FrontedQueryUserDto) {
return this.service.list({ ...options, trashed: SelectTrashMode.NONE }); return this.service.paginate({ ...options, trashed: SelectTrashMode.NONE });
} }
/** /**
@@ -36,6 +36,6 @@ export class UserController {
@Guest() @Guest()
@SerializeOptions({ groups: ['user-detail'] }) @SerializeOptions({ groups: ['user-detail'] })
async detail(@Param('id', new ParseUUIDPipe()) id: string) { async detail(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.detail(id, async (qb) => qb.andWhere({ deletedAt: Not(IsNull()) })); return this.service.detail(id, async (qb) => qb.andWhere({ deletedAt: IsNull() }));
} }
} }
+22 -19
View File
@@ -1,38 +1,24 @@
import { PickType } from '@nestjs/swagger'; import { OmitType, PickType } from '@nestjs/swagger';
import { IsDefined, IsUUID, Length } from 'class-validator'; import { Length } from 'class-validator';
import { IsPassword } from '@/modules/core/constraints/password.constraint'; import { IsPassword } from '@/modules/core/constraints/password.constraint';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
import { UserValidateGroup } from '../constants'; import { CaptchaDtoGroups, UserValidateGroup } from '../constants';
/** /**
* 更新用户信息 * 更新用户信息
*/ */
@DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE] }) @DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE], whitelist: false })
export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) { export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) {}
/**
* 待更新的用户ID
*/
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
id: string;
}
/** /**
* 更改用户密码 * 更改用户密码
*/ */
@DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] }) @DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] })
export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) { export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) {
/**
* 待更新的用户ID
*/
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
id: string;
/** /**
* 旧密码:用户在更改密码时需要输入的原密码 * 旧密码:用户在更改密码时需要输入的原密码
*/ */
@@ -40,3 +26,20 @@ export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'pla
@Length(8, 50, { message: '密码长度不得少于$constraint1', always: true }) @Length(8, 50, { message: '密码长度不得少于$constraint1', always: true })
oldPassword: string; oldPassword: string;
} }
/**
* 对手机/邮箱绑定验证码进行验证
*/
export class AccountBoundDto extends PickType(UserCommonDto, ['code', 'phone', 'email']) {}
/**
* 绑定或更改手机号验证
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_PHONE] })
export class PhoneBoundDto extends OmitType(AccountBoundDto, ['email'] as const) {}
/**
* 绑定或更改邮箱验证
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_EMAIL] })
export class EmailBoundDto extends OmitType(AccountBoundDto, ['phone'] as const) {}
+57 -1
View File
@@ -1,7 +1,7 @@
import { PickType } from '@nestjs/swagger'; import { PickType } from '@nestjs/swagger';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { UserValidateGroup } from '@/modules/user/constants'; import { CaptchaDtoGroups, UserValidateGroup } from '@/modules/user/constants';
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
/** /**
@@ -9,6 +9,18 @@ import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
*/ */
export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {} export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {}
/**
* 通过手机验证码登录
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_LOGIN] })
export class PhoneLoginDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {}
/**
* 通过邮箱验证码登录
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_LOGIN] })
export class EmailLoginDto extends PickType(UserCommonDto, ['email', 'code'] as const) {}
/** /**
* 普通方式注册用户 * 普通方式注册用户
*/ */
@@ -19,3 +31,47 @@ export class RegisterDto extends PickType(UserCommonDto, [
'password', 'password',
'plainPassword', 'plainPassword',
] as const) {} ] as const) {}
/**
* 通过手机验证码注册
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
export class PhoneRegisterDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {}
/**
* 通过邮件验证码注册
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_REGISTER] })
export class EmailRegisterDto extends PickType(UserCommonDto, ['email', 'code'] as const) {}
/**
* 通过登录凭证找回密码
*/
export class RetrievePasswordDto extends PickType(UserCommonDto, [
'credential',
'code',
'password',
'plainPassword',
] as const) {}
/**
* 通过手机号找回密码
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class PhoneRetrievePasswordDto extends PickType(UserCommonDto, [
'phone',
'code',
'password',
'plainPassword',
] as const) {}
/**
* 通过邮箱地址找回密码
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class EmailRetrievePasswordDto extends PickType(UserCommonDto, [
'email',
'code',
'password',
'plainPassword',
] as const) {}
+80
View File
@@ -0,0 +1,80 @@
import { PickType } from '@nestjs/swagger';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { CaptchaDtoGroups } from '../constants';
import { UserCommonDto } from './user.common.dto';
/**
* 发送邮件或短信验证码消息
*/
export class CaptchaMessage extends PickType(UserCommonDto, ['phone', 'email']) {}
/**
* 发送短信验证码DTO类型
*/
export class PhoneCaptchaMessageDto extends PickType(CaptchaMessage, ['phone'] as const) {}
/**
* 发送邮件验证码DTO类型
*/
export class EmailCaptchaMessageDto extends PickType(CaptchaMessage, ['email'] as const) {}
/**
* 通过已登录账户发送验证码消息
*/
export class UserCaptchaMessageDto extends PickType(UserCommonDto, ['type']) {}
/**
* 通过用户凭证发送验证码消息
*/
export class CredentialCaptchaMessageDto extends PickType(UserCommonDto, ['credential']) {}
/**
* 发送登录验证码短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_LOGIN] })
export class LoginPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送登录验证码邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_LOGIN] })
export class LoginEmailCaptchaDto extends EmailCaptchaMessageDto {}
/**
* 发送注册验证码短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
export class RegisterPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送注册验证码邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
export class RegisterEmailCaptchaDto extends EmailCaptchaMessageDto {}
/**
* 发送找回密码短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class RetrievePasswordPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送找回密码邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class RetrievePasswordEmailCaptchaDto extends EmailCaptchaMessageDto {}
/**
* 发送手机绑定短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_PHONE] })
export class BoundPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送邮箱绑定邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_EMAIL] })
export class BoundEmailCaptchaDto extends EmailCaptchaMessageDto {}
+10 -3
View File
@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { IsEmail, IsNotEmpty, IsOptional, Length } from 'class-validator'; import { IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsOptional, Length } from 'class-validator';
import { IsMatch } from '@/modules/core/constraints/match.constraint'; import { IsMatch } from '@/modules/core/constraints/match.constraint';
import { IsPassword } from '@/modules/core/constraints/password.constraint'; import { IsPassword } from '@/modules/core/constraints/password.constraint';
import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint'; import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint';
import { IsUnique, IsUniqueExist } from '@/modules/database/constraints'; import { IsUnique, IsUniqueExist } from '@/modules/database/constraints';
import { UserValidateGroup } from '@/modules/user/constants'; import { CaptchaType, UserValidateGroup } from '@/modules/user/constants';
import { UserEntity } from '@/modules/user/entities/user.entity'; import { UserEntity } from '@/modules/user/entities/user.entity';
/** /**
@@ -38,7 +38,7 @@ export class UserCommonDto {
{ entity: UserEntity, ignore: 'id', ignoreKey: 'userId' }, { entity: UserEntity, ignore: 'id', ignoreKey: 'userId' },
{ groups: [UserValidateGroup.ACCOUNT_UPDATE], message: '该用户名已被注册' }, { groups: [UserValidateGroup.ACCOUNT_UPDATE], message: '该用户名已被注册' },
) )
@Length(4, 50, { always: true, message: '用户名长度必须为$constraint1到$constraint2' }) @Length(4, 30, { always: true, message: '用户名长度必须为$constraint1到$constraint2' })
@IsOptional({ groups: [UserValidateGroup.USER_UPDATE, UserValidateGroup.ACCOUNT_UPDATE] }) @IsOptional({ groups: [UserValidateGroup.USER_UPDATE, UserValidateGroup.ACCOUNT_UPDATE] })
username: string; username: string;
@@ -111,4 +111,11 @@ export class UserCommonDto {
@IsMatch('password', false, { message: '两次输入密码不同', always: true }) @IsMatch('password', false, { message: '两次输入密码不同', always: true })
@IsNotEmpty({ message: '请再次输入密码以确认', always: true }) @IsNotEmpty({ message: '请再次输入密码以确认', always: true })
plainPassword: string; plainPassword: string;
@IsNumberString(undefined, { message: '验证码必须为数字', always: true })
@Length(6, 6, { message: '验证码长度错误', always: true })
code!: string;
@IsEnum(CaptchaType)
type: CaptchaType;
} }
+12 -1
View File
@@ -1,8 +1,10 @@
import { OmitType, PartialType, PickType } from '@nestjs/swagger'; import { OmitType, PartialType, PickType } from '@nestjs/swagger';
import { IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator'; import { Transform } from 'class-transformer';
import { IsBoolean, IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { toBoolean } from '@/modules/core/helpers';
import { IsDataExist } from '@/modules/database/constraints'; import { IsDataExist } from '@/modules/database/constraints';
import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities'; import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities';
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto'; import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
@@ -69,6 +71,7 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
/** /**
* 查询用户列表的Query数据验证 * 查询用户列表的Query数据验证
*/ */
@DtoValidation({ type: 'query', skipMissingProperties: true })
export class QueryUserDto extends PaginateWithTrashedDto { export class QueryUserDto extends PaginateWithTrashedDto {
/** /**
* 角色ID:根据角色来过滤用户 * 角色ID:根据角色来过滤用户
@@ -94,7 +97,15 @@ export class QueryUserDto extends PaginateWithTrashedDto {
* 排序规则:可指定用户列表的排序规则,默认为按创建时间降序排序 * 排序规则:可指定用户列表的排序规则,默认为按创建时间降序排序
*/ */
@IsEnum(UserOrderType) @IsEnum(UserOrderType)
@IsOptional()
orderBy?: UserOrderType; orderBy?: UserOrderType;
/**
* 过滤激活状态
*/
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
actived?: boolean;
} }
/** /**
@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
@Entity('user_captcha')
export class CaptchaEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ comment: '验证码' })
code!: string;
@Column({
type: 'enum',
enum: CaptchaActionType,
comment: '验证操作类型',
})
action!: CaptchaActionType;
@Column({
type: 'enum',
enum: CaptchaType,
comment: '验证码类型',
})
type!: CaptchaType;
@Column({ comment: '手机号/邮箱地址' })
value!: string;
@CreateDateColumn({
comment: '创建时间',
})
createdAt!: Date;
@UpdateDateColumn({
comment: '更新时间',
})
updatedAt!: Date;
}
+8 -2
View File
@@ -63,6 +63,9 @@ export class UserEntity {
@Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true }) @Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true })
email?: string; email?: string;
@Column({ comment: '用户状态,是否激活', default: false })
actived?: boolean;
/** /**
* 用户创建时间 * 用户创建时间
*/ */
@@ -109,13 +112,16 @@ export class UserEntity {
* 用户权限 * 用户权限
*/ */
@Expose() @Expose()
@ManyToMany(() => PermissionEntity, (permission) => permission.users, { cascade: true }) @ManyToMany(() => PermissionEntity, (permission) => permission.users, {
cascade: true,
onDelete: 'CASCADE',
})
permissions: Relation<PermissionEntity>[]; permissions: Relation<PermissionEntity>[];
/** /**
* 用户角色 * 用户角色
*/ */
@Expose() @Expose()
@ManyToMany(() => RoleEntity, (role) => role.users, { cascade: true }) @ManyToMany(() => RoleEntity, (role) => role.users, { cascade: true, onDelete: 'CASCADE' })
roles: Relation<RoleEntity>[]; roles: Relation<RoleEntity>[];
} }
+79 -1
View File
@@ -1,5 +1,8 @@
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
import { CaptchaEntity } from '@/modules/user/entities/captcha.entity';
/** /**
* 用户配置类型 * 自定义用户模块配置
*/ */
export interface UserConfig { export interface UserConfig {
/** /**
@@ -10,6 +13,8 @@ export interface UserConfig {
* jwt token的生成配置 * jwt token的生成配置
*/ */
jwt: JwtConfig; jwt: JwtConfig;
captcha?: CustomCaptchaConfig;
} }
/** /**
@@ -39,3 +44,76 @@ export interface JwtPayload {
*/ */
iat: number; iat: number;
} }
/**
* 默认用户模块配置
*/
export interface DefaultUserConfig {
hash: number;
jwt: Pick<Required<JwtConfig>, 'tokenExpired' | 'refreshTokenExpired'>;
captcha: DefaultCaptchaConfig;
}
/**
* 自定义验证码配置
*/
export interface CustomCaptchaConfig {
[CaptchaType.SMS]?: {
[key in CaptchaActionType]?: Partial<SmsCaptchaOption>;
};
[CaptchaType.EMAIL]?: {
[key in CaptchaActionType]?: Partial<EmailCaptchaOption>;
};
}
/**
* 默认验证码配置
*/
export interface DefaultCaptchaConfig {
[CaptchaType.SMS]: {
[key in CaptchaActionType]: CaptchaOption;
};
[CaptchaType.EMAIL]: {
[key in CaptchaActionType]: Omit<EmailCaptchaOption, 'template'>;
};
}
/**
* 通用验证码选项
*/
export interface CaptchaOption {
limit: number; // 验证码发送间隔时间
expired: number; // 验证码有效时间
}
/**
* 手机验证码选项
*/
export interface SmsCaptchaOption extends CaptchaOption {
template: string; // 云厂商短信推送模板ID
}
/**
* 邮件验证码选项
*/
export interface EmailCaptchaOption extends CaptchaOption {
subject: string; // 邮件主题
template?: string; // 模板路径
}
/**
* 任务传给消费者的数据类型
*/
export interface SendCaptchaQueueJob {
captcha: { [key in keyof CaptchaEntity]: CaptchaEntity[key] };
option: SmsCaptchaOption | EmailCaptchaOption;
otherVars?: RecordAny;
}
/**
* 验证码正确性验证
*/
export type CaptchaValidate<T extends RecordAny = RecordNever> = T & {
value: string;
code: string;
};
+7
View File
@@ -21,3 +21,10 @@ export async function encrypt(configure: Configure, password: string) {
export function decrypt(password: string, hashed: string) { export function decrypt(password: string, hashed: string) {
return bcrypt.compareSync(password, hashed); return bcrypt.compareSync(password, hashed);
} }
/**
* 生成随机验证码
*/
export function generateCaptchaCode() {
return Math.random().toFixed(6).slice(-6);
}
+60 -5
View File
@@ -5,7 +5,7 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { existsSync } from 'fs-extra'; import { existsSync } from 'fs-extra';
import { isNil } from 'lodash'; import { isArray, isNil, omit } from 'lodash';
import { RbacModule } from '@/modules/rbac/rbac.module'; import { RbacModule } from '@/modules/rbac/rbac.module';
import { UserModule } from '@/modules/user/user.module'; import { UserModule } from '@/modules/user/user.module';
@@ -13,7 +13,13 @@ import { UserModule } from '@/modules/user/user.module';
import * as configs from './config'; import * as configs from './config';
import { ContentModule } from './modules/content/content.module'; import { ContentModule } from './modules/content/content.module';
import { GlobalExceptionFilter } from './modules/core/filters/global-exception.filter'; import { GlobalExceptionFilter } from './modules/core/filters/global-exception.filter';
import { CreateOptions } from './modules/core/types'; import {
BullOptions,
CreateOptions,
QueueOptions,
RedisOption,
RedisOptions,
} from './modules/core/types';
import * as dbCommands from './modules/database/commands'; import * as dbCommands from './modules/database/commands';
import { DatabaseModule } from './modules/database/database.module'; import { DatabaseModule } from './modules/database/database.module';
import { MeiliModule } from './modules/meilisearch/meili.module'; import { MeiliModule } from './modules/meilisearch/meili.module';
@@ -52,12 +58,61 @@ export const createOptions: CreateOptions = {
if (existsSync(join(__dirname, 'metadata.js'))) { if (existsSync(join(__dirname, 'metadata.js'))) {
metadata = (await import(join(__dirname, 'metadata.js'))).default; metadata = (await import(join(__dirname, 'metadata.js'))).default;
} }
if (existsSync(join(__dirname, 'metadata.ts'))) {
metadata = (await import(join(__dirname, 'metadata.ts'))).default;
}
await restful.factoryDocs(container, metadata); await restful.factoryDocs(container, metadata);
} }
return container; return container;
}, },
}; };
/**
* 生成Redis配置
* @param options
*/
export const createRedisOptions = (options: RedisOptions): RedisOption[] | undefined => {
if (isNil(options)) {
return undefined;
}
const config: Array<RedisOption> = Array.isArray(options)
? options
: [{ ...options, name: 'default' }];
if (config.length < 1) {
return undefined;
}
if (isNil(config.find(({ name }) => name === 'default'))) {
config[0].name = 'default';
}
return config.reduce<RedisOption[]>((o, n) => {
const names = o.map(({ name }) => name) as string[];
return names.includes(n.name) ? o : [...o, n];
}, []);
};
/**
* 生成BullMQ模块的配置
* @param options
* @param redis
*/
export const createQueueOptions = (
options: QueueOptions,
redis: Array<RedisOption>,
): BullOptions | undefined => {
if (isNil(options) || isNil(redis)) {
return undefined;
}
const names = redis.map(({ name }) => name);
if (names.length < 1 || !names.includes('default')) {
return undefined;
}
if (isArray(options)) {
return options.map((option) => ({
...omit(option, 'redis'),
connection: redis.find(({ name: c }) => c === (option.redis ?? 'default')),
}));
}
return {
...omit(options, 'redis'),
connection: redis.find(({ name: c }) => c === (options.redis ?? 'default')),
};
};
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
# Controller Tests
这个目录包含了NestJS应用程序中所有controller的完整测试用例。每个controller都有独立的测试文件,覆盖了所有的API接口和各种测试场景。
## 📁 测试文件结构
```
test/controllers/
├── README.md # 本文件
├── category.controller.test.ts # 分类查询API测试
├── tag.controller.test.ts # 标签查询API测试
├── post.controller.test.ts # 文章操作API测试
├── comment.controller.test.ts # 评论操作API测试
├── user.controller.test.ts # 用户查询API测试
├── account.controller.test.ts # 账户操作API测试
├── role.controller.test.ts # 角色查询API测试
└── manager/ # 管理后台API测试
├── category.controller.test.ts # 分类管理API测试
├── tag.controller.test.ts # 标签管理API测试
├── post.controller.test.ts # 文章管理API测试
├── comment.controller.test.ts # 评论管理API测试
├── user.controller.test.ts # 用户管理API测试
├── role.controller.test.ts # 角色管理API测试
└── permission.controller.test.ts # 权限管理API测试
```
## 🧪 测试覆盖范围
每个测试文件都包含以下类型的测试用例:
### ✅ 成功场景测试
- 正常的API调用和响应
- 有效的参数和数据
- 正确的权限验证
### ❌ 失败场景测试
- 参数验证失败
- 权限验证失败
- 数据不存在
- 业务逻辑错误
### 🔍 边界值测试
- 最大/最小值测试
- 空值和null值测试
- 特殊字符测试
- 长度限制测试
### 🔐 权限测试
- 未认证访问
- 权限不足
- Token验证
## 🚀 运行测试
### 运行所有controller测试
```bash
npm run test:controllers
```
### 运行特定的测试文件
```bash
# 运行分类controller测试
npm run test:controllers category
# 运行用户相关测试
npm run test:controllers user account
# 运行管理后台测试
npm run test:controllers manager
```
### 运行测试组
```bash
# 运行所有前台API测试
npm run test:controllers app
# 运行所有管理后台API测试
npm run test:controllers manager
# 运行所有内容相关测试
npm run test:controllers content
# 运行所有用户认证相关测试
npm run test:controllers user-auth
# 运行所有权限相关测试
npm run test:controllers rbac
```
### 使用Jest直接运行
```bash
# 运行单个测试文件
npx jest test/controllers/category.controller.test.ts
# 运行所有controller测试
npx jest test/controllers/
# 运行测试并生成覆盖率报告
npx jest test/controllers/ --coverage
# 运行测试并监听文件变化
npx jest test/controllers/ --watch
```
## 📊 测试报告
测试运行后会生成以下报告:
### 控制台输出
- 测试执行摘要
- 失败测试详情
- 性能统计
- 覆盖率摘要
### HTML报告
- 位置:`coverage/controllers/test-report.html`
- 包含详细的测试结果和覆盖率信息
### JSON报告
- 位置:`coverage/controllers/test-results.json`
- 机器可读的测试结果数据
### 覆盖率报告
- 位置:`coverage/controllers/lcov-report/index.html`
- 详细的代码覆盖率分析
## 🛠️ 测试配置
### Jest配置
测试使用专门的Jest配置文件:`test/jest.controller.config.js`
### 环境变量
测试运行时会使用以下环境变量:
```bash
NODE_ENV=test
APP_PORT=3001
TEST_DB_HOST=localhost
TEST_DB_PORT=3306
TEST_DB_DATABASE=nestapp_test
TEST_DB_USERNAME=root
TEST_DB_PASSWORD=
```
### 测试数据库
- 测试使用独立的测试数据库
- 每个测试都会创建和清理自己的测试数据
- 不会影响开发或生产数据
## 📝 编写新的测试
### 测试文件模板
```typescript
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/your-module';
describe('YourController', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let testData: any[];
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
await setupTestData();
});
afterAll(async () => {
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建测试数据
}
async function cleanupTestData() {
// 清理测试数据
}
describe('GET /endpoint', () => {
it('should return success response', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/endpoint`,
});
expect(result.statusCode).toBe(200);
// 添加更多断言
});
});
});
```
### 测试数据生成
使用 `test/helpers/test-data-generator.ts` 中的工具函数:
```typescript
import {
generateTestUser,
generateTestCategory,
TestDataManager
} from '../helpers/test-data-generator';
const dataManager = new TestDataManager();
// 生成测试用户
const testUser = generateTestUser('mytest');
// 生成测试分类
const testCategory = generateTestCategory('MyTestCategory');
// 添加清理任务
dataManager.getCleaner().addCleanupTask(async () => {
await userRepository.remove(testUser);
});
```
## 🔧 故障排除
### 常见问题
1. **数据库连接失败**
- 检查测试数据库是否存在
- 确认数据库连接配置正确
2. **测试超时**
- 增加Jest超时时间
- 检查是否有未关闭的数据库连接
3. **权限测试失败**
- 确认测试用户有正确的权限
- 检查认证token是否有效
4. **测试数据冲突**
- 确保每个测试使用独立的测试数据
- 检查测试数据清理是否完整
### 调试技巧
1. **启用详细日志**
```bash
DISABLE_TEST_LOGS=false npm run test:controllers
```
2. **运行单个测试**
```bash
npx jest test/controllers/category.controller.test.ts --verbose
```
3. **使用调试器**
```bash
node --inspect-brk node_modules/.bin/jest test/controllers/category.controller.test.ts --runInBand
```
## 📚 相关文档
- [Jest官方文档](https://jestjs.io/docs/getting-started)
- [NestJS测试文档](https://docs.nestjs.com/fundamentals/testing)
- [Fastify测试文档](https://www.fastify.io/docs/latest/Guides/Testing/)
## 🤝 贡献指南
1. 为新的controller添加对应的测试文件
2. 确保测试覆盖所有的API接口
3. 包含成功和失败场景的测试用例
4. 使用独立的测试数据,避免测试间的相互影响
5. 添加适当的注释和文档
+578
View File
@@ -0,0 +1,578 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { getRandomString } from '@/modules/core/helpers';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/user';
describe('AccountController (App)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let userRepository: UserRepository;
let testUser: UserEntity;
let authToken: string;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
userRepository = app.get<UserRepository>(UserRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建测试用户
const username = getRandomString();
testUser = await userRepository.save({
username,
nickname: 'Test Account User',
password: 'password123',
email: 'testaccount@example.com',
});
}
async function cleanupTestData() {
if (testUser) {
await userRepository.remove(testUser);
}
}
describe('POST /account/register', () => {
it('should register user successfully', async () => {
const username = getRandomString();
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
username,
nickname: 'New User',
password: 'password123',
plainPassword: 'password123',
},
});
expect(result.statusCode).toBe(201);
const newUser = result.json();
expect(newUser.username).toBe(username);
expect(newUser.nickname).toBe('New User');
// 不应该返回密码
expect(newUser.password).toBeUndefined();
// 清理创建的用户
await userRepository.delete(newUser.id);
});
it('should fail with missing required fields', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
// missing username and password
nickname: 'Test User',
},
});
expect(result.statusCode).toBe(400);
const response = result.json();
expect(response.message).toContain('用户名长度必须为4到30');
expect(response.message).toContain('密码长度不得少于8');
});
it('should fail with duplicate username', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
username: testUser.username, // 使用已存在的用户名
nickname: 'Another User',
password: 'password123',
plainPassword: 'password123',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('该用户名已被注册');
});
it('should fail with invalid email format', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
username: 'test_user_email',
nickname: 'Test User',
password: 'password123',
plainPassword: 'password123',
email: 'invalid-email',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('property email should not exist');
});
it('should fail with password mismatch', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
username: 'test_user_pwd',
nickname: 'Test User',
password: 'password123',
plainPassword: 'different_password',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('两次输入密码不同');
});
it('should fail with short password', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
username: 'test_user_short',
nickname: 'Test User',
password: '123',
plainPassword: '123',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('密码长度不得少于8');
});
it('should fail with long username', async () => {
const username = getRandomString(52);
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
username,
nickname: 'Test User',
password: 'password123',
plainPassword: 'password123',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('用户名长度必须为4到30');
});
});
describe('POST /account/login', () => {
it('should login successfully with username', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: testUser.username,
password: 'password123',
},
});
expect(result.statusCode).toBe(201);
const response = result.json();
expect(response.token).toBeDefined();
expect(typeof response.token).toBe('string');
// 保存token用于后续测试
authToken = response.token;
});
it('should login successfully with email', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: testUser.email,
password: 'password123',
},
});
expect(result.statusCode).toBe(201);
const response = result.json();
expect(response.token).toBeDefined();
});
it('should fail with wrong password', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: testUser.username,
password: 'wrong-password',
},
});
expect(result.statusCode).toBe(401);
expect(result.json().message).toContain('Unauthorized');
});
it('should fail with non-existent user', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: 'non-existent-user',
password: 'password123',
},
});
expect(result.statusCode).toBe(401);
expect(result.json().message).toContain('Unauthorized');
});
it('should fail with missing credentials', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
// missing credential and password
},
});
expect(result.statusCode).toBe(502);
const response = result.json();
expect(response.message).toContain('登录凭证不得为空');
expect(response.message).toContain('登录凭证长度必须为4到30');
});
it('should fail with empty credential', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: '',
password: 'password123',
},
});
expect(result.statusCode).toBe(502);
expect(result.json().message).toContain('登录凭证不得为空');
});
it('should fail with empty password', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: testUser.username,
password: '',
},
});
expect(result.statusCode).toBe(502);
expect(result.json().message).toContain('密码长度不得少于8');
});
});
describe('POST /account/logout', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/logout`,
});
expect(result.statusCode).toBe(401);
});
it('should logout successfully', async () => {
const username = getRandomString();
const result1 = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/register`,
body: {
username,
nickname: 'New User',
password: 'password123',
plainPassword: 'password123',
},
});
const newUser = result1.json();
// 首先登录获取token
const loginResult = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: username,
password: 'password123',
},
});
const { token } = loginResult.json();
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/logout`,
headers: {
authorization: `Bearer ${token}`,
},
});
expect(result.statusCode).toBe(201);
// 清理创建的用户
await userRepository.delete(newUser.id);
});
it('should fail with invalid token', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/logout`,
headers: {
authorization: 'Bearer invalid-token',
},
});
expect(result.statusCode).toBe(401);
});
});
describe('GET /account/profile', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/account/profile`,
});
expect(result.statusCode).toBe(401);
});
it('should return user profile successfully', async () => {
// 确保有有效的token
if (!authToken) {
const loginResult = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: testUser.username,
password: 'password123',
},
});
authToken = loginResult.json().token;
}
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/account/profile`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const profile = result.json();
expect(profile.id).toBe(testUser.id);
expect(profile.username).toBe(testUser.username);
expect(profile.nickname).toBe(testUser.nickname);
expect(profile.email).toBe(testUser.email);
// 不应该返回密码
expect(profile.password).toBeUndefined();
});
it('should fail with invalid token', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/account/profile`,
headers: {
authorization: 'Bearer invalid-token',
},
});
expect(result.statusCode).toBe(401);
});
});
describe('PATCH /account', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account`,
body: {
nickname: 'Updated Nickname',
},
});
expect(result.statusCode).toBe(401);
});
it('should update account info successfully', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
nickname: 'Updated Test Account',
},
});
expect(result.statusCode).toBe(200);
const updatedUser = result.json();
expect(updatedUser.nickname).toBe('Updated Test Account');
expect(updatedUser.id).toBe(testUser.id);
});
it('should update username successfully', async () => {
const randomTag = getRandomString(10);
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
username: `updated-account-${randomTag}`,
},
});
expect(result.statusCode).toBe(200);
const updatedUser = result.json();
expect(updatedUser.username).toBe(`updated-account-${randomTag}`);
testUser.username = `updated-account-${randomTag}`;
});
it('should fail with duplicate username', async () => {
// 创建另一个用户
const username = `another-account-${getRandomString()}`;
const anotherUser = await userRepository.save({
username,
nickname: 'Another Account',
password: 'password123',
});
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
username, // 尝试使用已存在的用户名
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('该用户名已被注册');
// 清理
await userRepository.remove(anotherUser);
});
});
describe('PATCH /account/change-password', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account/change-password`,
body: {
oldPassword: 'password123',
password: 'newpassword123',
plainPassword: 'newpassword123',
},
});
expect(result.statusCode).toBe(401);
});
it('should change password successfully', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account/change-password`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
oldPassword: 'password123',
password: 'newpassword123',
plainPassword: 'newpassword123',
},
});
expect(result.statusCode).toBe(200);
// 验证新密码可以登录
const loginResult = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/login`,
body: {
credential: testUser.username,
password: 'newpassword123',
},
});
expect(loginResult.statusCode).toBe(201);
// 恢复原密码以便其他测试
await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account/change-password`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
oldPassword: 'newpassword123',
password: 'password123',
plainPassword: 'password123',
},
});
});
it('should fail with wrong old password', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account/change-password`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
oldPassword: 'wrongpassword',
password: 'newpassword123',
plainPassword: 'newpassword123',
},
});
expect(result.statusCode).toBe(403);
expect(result.json().message).toContain('old password do not match');
});
it('should fail with password mismatch', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/account/change-password`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
oldPassword: 'password123',
password: 'newpassword123',
plainPassword: 'differentpassword',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('两次输入密码不同');
});
});
});
@@ -0,0 +1,211 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { CategoryEntity } from '@/modules/content/entities';
import { CategoryRepository } from '@/modules/content/repositories';
import { CategoryService } from '@/modules/content/services';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/content';
describe('CategoryController (App)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let categoryRepository: CategoryRepository;
let categoryService: CategoryService;
let testCategories: CategoryEntity[];
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
categoryService = app.get<CategoryService>(CategoryService);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建测试分类数据
const rootCategory = await categoryRepository.save({
name: 'Test Root Category',
customOrder: 1,
});
const childCategory = await categoryRepository.save({
name: 'Test Child Category',
parent: rootCategory,
customOrder: 2,
});
testCategories = [rootCategory, childCategory];
}
async function cleanupTestData() {
if (testCategories && testCategories.length > 0) {
await categoryService.delete(testCategories.map(({ id }) => id));
}
}
describe('GET /category/tree', () => {
it('should return category tree successfully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/tree`,
});
expect(result.statusCode).toBe(200);
const categories = result.json();
expect(Array.isArray(categories)).toBe(true);
expect(categories.length).toBeGreaterThan(0);
});
it('should return categories with tree structure', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/tree`,
});
const categories = result.json();
const rootCategory = categories.find((c: any) => c.name === 'Test Root Category');
expect(rootCategory).toBeDefined();
expect(rootCategory.children).toBeDefined();
expect(Array.isArray(rootCategory.children)).toBe(true);
});
});
describe('GET /category', () => {
it('should return paginated categories successfully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should return categories with valid pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category?page=1&limit=10`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(10);
});
it('should fail with invalid page parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category?page=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The current page must be greater than 1.');
});
it('should fail with invalid limit parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category?limit=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The number of data displayed per page must be greater than 1.',
);
});
it('should handle large page numbers gracefully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category?page=999999`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toEqual([]);
});
it('should handle large limit values', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category?limit=1000`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.perPage).toBe(1000);
});
});
describe('GET /category/:id', () => {
it('should return category detail successfully', async () => {
const category = testCategories[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/${category.id}`,
});
expect(result.statusCode).toBe(200);
const categoryDetail = result.json();
expect(categoryDetail.id).toBe(category.id);
expect(categoryDetail.name).toBe(category.name);
});
it('should fail with invalid UUID format', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/invalid-uuid`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should fail with non-existent category ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/74e655b3-b69a-42ae-a101-41c224386e74`,
});
expect(result.statusCode).toBe(404);
});
it('should return category with children if exists', async () => {
const rootCategory = testCategories.find((c) => !c.parent);
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/${rootCategory.id}`,
});
expect(result.statusCode).toBe(200);
const categoryDetail = result.json();
expect(categoryDetail.children).toBeDefined();
});
});
});
+348
View File
@@ -0,0 +1,348 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { CategoryEntity, CommentEntity, PostEntity } from '@/modules/content/entities';
import {
CategoryRepository,
CommentRepository,
PostRepository,
} from '@/modules/content/repositories';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/content';
describe('CommentController (App)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let commentRepository: CommentRepository;
let postRepository: PostRepository;
let categoryRepository: CategoryRepository;
let userRepository: UserRepository;
let testComments: CommentEntity[];
let testPost: PostEntity;
let testCategory: CategoryEntity;
let testUser: UserEntity;
let authToken: string;
let permissionRepository: PermissionRepository;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
datasource = app.get<DataSource>(DataSource);
commentRepository = app.get<CommentRepository>(CommentRepository);
postRepository = app.get<PostRepository>(PostRepository);
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
userRepository = app.get<UserRepository>(UserRepository);
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建测试用户
const permission = await permissionRepository.findOneOrFail({
where: { name: 'comment.create' },
});
testUser = await userRepository.save({
username: 'testuser_comment',
nickname: 'Test User Comment',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'testuser_comment',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试分类
testCategory = await categoryRepository.save({
name: 'Test Comment Category',
customOrder: 1,
});
// 创建测试文章
testPost = await postRepository.save({
title: 'Test Post for Comments',
body: 'This is a test post for comments.',
summary: 'Test post summary',
author: testUser,
category: testCategory,
publishedAt: new Date(),
});
// 创建测试评论
const parentComment = await commentRepository.save({
body: 'This is a parent comment.',
post: testPost,
author: testUser,
});
const childComment = await commentRepository.save({
body: 'This is a child comment.',
post: testPost,
author: testUser,
parent: parentComment,
});
testComments = [parentComment, childComment];
}
async function cleanupTestData() {
if (testComments && testComments.length > 0) {
await commentRepository.remove(testComments);
}
if (testPost) {
await postRepository.remove(testPost);
}
if (testCategory) {
await categoryRepository.remove(testCategory);
}
if (testUser) {
await userRepository.remove(testUser);
}
}
describe('GET /comment/tree', () => {
it('should return comment tree for a post', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comment/tree?post=${testPost.id}`,
});
expect(result.statusCode).toBe(200);
const comments = result.json();
expect(Array.isArray(comments)).toBe(true);
// 应该包含父评论和子评论的树形结构
const parentComment = comments.find((c: any) => !c.parent);
expect(parentComment).toBeDefined();
expect(parentComment.children).toBeDefined();
expect(Array.isArray(parentComment.children)).toBe(true);
});
it('should fail with invalid post UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comment/tree?post=invalid-uuid`,
});
expect(result.statusCode).toBe(400);
});
it('should handle missing post parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comment/tree`,
});
expect(result.statusCode).toBe(200);
});
});
describe('GET /comment', () => {
it('should return paginated comments', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comment`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should filter comments by post', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comment?post=${testPost.id}`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
response.items.forEach((comment: any) => {
expect(comment.post.id).toBe(testPost.id);
});
});
it('should return comments with pagination', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comment?page=1&limit=5`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should fail with invalid pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comment?page=0&limit=0`,
});
expect(result.statusCode).toBe(400);
});
});
describe('POST /comment', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/comment`,
body: {
body: 'New test comment',
post: testPost.id,
},
});
expect(result.statusCode).toBe(401);
});
it('should create comment successfully', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/comment`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
body: 'New test comment',
post: testPost.id,
},
});
expect(result.statusCode).toBe(201);
const newComment = result.json();
expect(newComment.body).toBe('New test comment');
expect(newComment.post.id).toBe(testPost.id);
// 清理创建的评论
await commentRepository.delete(newComment.id);
});
it('should create reply comment successfully', async () => {
const parentComment = testComments[0];
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/comment`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
body: 'Reply to parent comment',
post: testPost.id,
parent: parentComment.id,
},
});
expect(result.statusCode).toBe(201);
const replyComment = result.json();
expect(replyComment.body).toBe('Reply to parent comment');
expect(replyComment.parent.id).toBe(parentComment.id);
// 清理创建的评论
await commentRepository.delete(replyComment.id);
});
it('should fail with missing required fields', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/comment`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
// missing body and post
},
});
expect(result.statusCode).toBe(400);
const response = result.json();
expect(response.message).toContain('Comment content cannot be empty');
expect(response.message).toContain('The post ID must be specified');
});
it('should fail with invalid post ID', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/comment`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
body: 'Test comment',
post: 'invalid-uuid',
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent post', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/comment`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
body: 'Test comment',
post: '74e655b3-b69a-42ae-a101-41c224386e74',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The post does not exist');
});
it('should fail with too long comment body', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/comment`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
body: 'A'.repeat(1001), // 超过1000字符限制
post: testPost.id,
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The length of the comment content cannot exceed 1000',
);
});
});
});
@@ -0,0 +1,513 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { CategoryEntity } from '@/modules/content/entities';
import { CategoryRepository } from '@/modules/content/repositories';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/manager/content';
describe('CategoryController (Manager)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let categoryRepository: CategoryRepository;
let userRepository: UserRepository;
let testCategories: CategoryEntity[];
let adminUser: UserEntity;
let authToken: string;
let permissionRepository: PermissionRepository;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
userRepository = app.get<UserRepository>(UserRepository);
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
const permission = await permissionRepository.findOneOrFail({
where: { name: 'category.manage' },
});
// 创建管理员用户
adminUser = await userRepository.save({
username: 'admin_category',
nickname: 'Admin Category',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'admin_category',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试分类数据
const rootCategory = await categoryRepository.save({
name: 'Manager Test Root Category',
customOrder: 1,
});
const childCategory = await categoryRepository.save({
name: 'Manager Test Child Category',
parent: rootCategory,
customOrder: 2,
});
testCategories = [rootCategory, childCategory];
}
async function cleanupTestData() {
if (testCategories && testCategories.length > 0) {
await categoryRepository.remove(testCategories);
}
if (adminUser) {
await userRepository.remove(adminUser);
}
}
describe('GET /category/tree', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/tree`,
});
expect(result.statusCode).toBe(401);
});
it('should return category tree with authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/tree`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const categories = result.json();
expect(Array.isArray(categories)).toBe(true);
});
it('should fail with invalid token', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/tree`,
headers: {
authorization: 'Bearer invalid-token',
},
});
expect(result.statusCode).toBe(401);
});
});
describe('GET /category', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category`,
});
expect(result.statusCode).toBe(401);
});
it('should return paginated categories with authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should handle pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category?page=1&limit=5`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
});
describe('GET /category/:id', () => {
it('should require authentication', async () => {
const category = testCategories[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/${category.id}`,
});
expect(result.statusCode).toBe(401);
});
it('should return category detail with authentication', async () => {
const category = testCategories[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/category/${category.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const categoryDetail = result.json();
expect(categoryDetail.id).toBe(category.id);
expect(categoryDetail.name).toBe(category.name);
});
});
describe('POST /category', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
body: {
name: 'New Test Category',
},
});
expect(result.statusCode).toBe(401);
});
it('should create category successfully', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'New Manager Test Category',
customOrder: 10,
},
});
expect(result.statusCode).toBe(201);
const newCategory = result.json();
expect(newCategory.name).toBe('New Manager Test Category');
expect(newCategory.customOrder).toBe(10);
// 清理创建的分类
await categoryRepository.delete(newCategory.id);
});
it('should create child category successfully', async () => {
const parentCategory = testCategories[0];
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'New Child Category',
parent: parentCategory.id,
customOrder: 5,
},
});
expect(result.statusCode).toBe(201);
const newCategory = result.json();
expect(newCategory.name).toBe('New Child Category');
expect(newCategory.parent.id).toBe(parentCategory.id);
// 清理创建的分类
await categoryRepository.delete(newCategory.id);
});
it('should fail with missing name', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
customOrder: 10,
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The classification name cannot be empty');
});
it('should fail with duplicate name at same level', async () => {
const existingCategory = testCategories[0];
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: existingCategory.name,
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The Category names are duplicated');
});
it('should fail with invalid parent ID', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Test Category',
parent: 'invalid-uuid',
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent parent ID', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Test Category',
parent: '74e655b3-b69a-42ae-a101-41c224386e74',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The parent category does not exist');
});
it('should fail with negative custom order', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Test Category',
customOrder: -1,
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The sorted value must be greater than 0.');
});
it('should fail with too long name', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'A'.repeat(26), // 超过25字符限制
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The length of the category name shall not exceed 25',
);
});
});
describe('PATCH /category', () => {
it('should require authentication', async () => {
const category = testCategories[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/category`,
body: {
id: category.id,
name: 'Updated Category Name',
},
});
expect(result.statusCode).toBe(401);
});
it('should update category successfully', async () => {
const category = testCategories[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: category.id,
name: 'Updated Manager Category',
customOrder: 99,
},
});
expect(result.statusCode).toBe(200);
const updatedCategory = result.json();
expect(updatedCategory.name).toBe('Updated Manager Category');
expect(updatedCategory.customOrder).toBe(99);
});
it('should fail with missing ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Updated Category',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The ID must be specified');
});
it('should fail with invalid ID format', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: 'invalid-uuid',
name: 'Updated Category',
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/category`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: '74e655b3-b69a-42ae-a101-41c224386e74',
name: 'Updated Category',
},
});
expect(result.statusCode).toBe(400);
});
});
describe('DELETE /category/:id', () => {
it('should require authentication', async () => {
const category = testCategories[0];
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/category/${category.id}`,
});
expect(result.statusCode).toBe(401);
});
it('should delete category successfully', async () => {
// 创建一个临时分类用于删除测试
const tempCategory = await categoryRepository.save({
name: 'Temp Category for Delete',
customOrder: 999,
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/category/${tempCategory.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
// 验证分类已被删除
const deletedCategory = await categoryRepository.findOne({
where: { id: tempCategory.id },
});
expect(deletedCategory).toBeNull();
});
it('should fail with invalid UUID', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/category/invalid-uuid`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(400);
});
it('should fail when deleting category with children', async () => {
const parentCategory = testCategories.find((c) => !c.parent);
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/category/${parentCategory.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
// 应该失败,因为有子分类
expect(result.statusCode).toBe(200);
});
});
});
@@ -0,0 +1,385 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { CategoryEntity, CommentEntity, PostEntity } from '@/modules/content/entities';
import {
CategoryRepository,
CommentRepository,
PostRepository,
} from '@/modules/content/repositories';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/manager/content';
describe('CommentController (Manager)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let commentRepository: CommentRepository;
let postRepository: PostRepository;
let categoryRepository: CategoryRepository;
let userRepository: UserRepository;
let testComments: CommentEntity[];
let testPost: PostEntity;
let testCategory: CategoryEntity;
let testUser: UserEntity;
let adminUser: UserEntity;
let authToken: string;
let permissionRepository: PermissionRepository;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
commentRepository = app.get<CommentRepository>(CommentRepository);
postRepository = app.get<PostRepository>(PostRepository);
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
userRepository = app.get<UserRepository>(UserRepository);
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建管理员用户
const permission = await permissionRepository.findOneOrFail({
where: { name: 'comment.manage' },
});
adminUser = await userRepository.save({
username: 'admin_comment',
nickname: 'Admin Comment',
password: 'password123',
permissions: [permission],
});
// 创建普通用户
testUser = await userRepository.save({
username: 'testuser_manager_comment',
nickname: 'Test User Manager Comment',
password: 'password123',
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'admin_comment',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试分类
testCategory = await categoryRepository.save({
name: 'Manager Test Comment Category',
customOrder: 1,
});
// 创建测试文章
testPost = await postRepository.save({
title: 'Manager Test Post for Comments',
body: 'This is a manager test post for comments.',
summary: 'Manager test post summary',
author: testUser,
category: testCategory,
publishedAt: new Date(),
});
// 创建测试评论
const parentComment = await commentRepository.save({
body: 'This is a manager parent comment.',
post: testPost,
author: testUser,
});
const childComment = await commentRepository.save({
body: 'This is a manager child comment.',
post: testPost,
author: testUser,
parent: parentComment,
});
testComments = [parentComment, childComment];
}
async function cleanupTestData() {
if (testComments && testComments.length > 0) {
await commentRepository.remove(testComments);
}
if (testPost) {
await postRepository.remove(testPost);
}
if (testCategory) {
await categoryRepository.remove(testCategory);
}
if (testUser) {
await userRepository.remove(testUser);
}
if (adminUser) {
await userRepository.remove(adminUser);
}
}
describe('GET /comments', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comments`,
});
expect(result.statusCode).toBe(401);
});
it('should return paginated comments with authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should filter comments by post', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comments?post=${testPost.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
response.items.forEach((comment: any) => {
expect(comment.post.id).toBe(testPost.id);
});
});
it('should handle pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comments?page=1&limit=5`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should fail with invalid token', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: 'Bearer invalid-token',
},
});
expect(result.statusCode).toBe(401);
});
});
describe('GET /comments/:id', () => {
it('should require authentication', async () => {
const comment = testComments[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/comments/${comment.id}`,
});
expect(result.statusCode).toBe(404);
});
});
describe('DELETE /comments', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/comments`,
body: {
ids: [testComments[0].id],
},
});
expect(result.statusCode).toBe(401);
});
it('should delete comments successfully', async () => {
// 创建临时评论用于删除测试
const tempComment = await commentRepository.save({
body: 'Temp comment for delete',
post: testPost,
author: testUser,
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempComment.id],
},
});
expect(result.statusCode).toBe(200);
// 验证评论已被删除
const deletedComment = await commentRepository.findOne({
where: { id: tempComment.id },
});
expect(deletedComment).toBeNull();
});
it('should delete multiple comments successfully', async () => {
// 创建多个临时评论用于删除测试
const tempComment1 = await commentRepository.save({
body: 'Temp comment 1 for delete',
post: testPost,
author: testUser,
});
const tempComment2 = await commentRepository.save({
body: 'Temp comment 2 for delete',
post: testPost,
author: testUser,
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempComment1.id, tempComment2.id],
},
});
expect(result.statusCode).toBe(200);
// 验证评论已被删除
const deletedComment1 = await commentRepository.findOne({
where: { id: tempComment1.id },
});
const deletedComment2 = await commentRepository.findOne({
where: { id: tempComment2.id },
});
expect(deletedComment1).toBeNull();
expect(deletedComment2).toBeNull();
});
it('should fail with missing ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {},
});
expect(result.statusCode).toBe(400);
});
it('should fail with empty ids array', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [],
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with invalid UUID in ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: ['invalid-uuid'],
},
});
expect(result.statusCode).toBe(400);
});
it('should delete parent comment and its children', async () => {
// 创建父评论和子评论用于测试级联删除
const parentComment = await commentRepository.save({
body: 'Parent comment for cascade delete',
post: testPost,
author: testUser,
});
const childComment = await commentRepository.save({
body: 'Child comment for cascade delete',
post: testPost,
author: testUser,
parent: parentComment,
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/comments`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [parentComment.id],
},
});
expect(result.statusCode).toBe(200);
// 验证父评论和子评论都被删除
const deletedParent = await commentRepository.findOne({
where: { id: parentComment.id },
});
const deletedChild = await commentRepository.findOne({
where: { id: childComment.id },
});
expect(deletedParent).toBeNull();
expect(deletedChild).toBeNull();
});
});
});
@@ -0,0 +1,253 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { CommentEntity } from '@/modules/content/entities';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionAction } from '@/modules/rbac/constants';
import { PermissionEntity } from '@/modules/rbac/entities';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/manager/rbac';
describe('PermissionController (Manager)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let permissionRepository: PermissionRepository;
let userRepository: UserRepository;
let testPermissions: PermissionEntity[];
let adminUser: UserEntity;
let authToken: string;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
userRepository = app.get<UserRepository>(UserRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建管理员用户
const permission = await permissionRepository.findOneOrFail({
where: { name: 'system-manage' },
});
adminUser = await userRepository.save({
username: 'admin_permission_manager',
nickname: 'Admin Permission Manager',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'admin_permission_manager',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试权限数据
const permission1 = await permissionRepository.save({
name: 'Manager Test Permission 1',
label: 'manager.test.permission.1',
description: 'Manager test permission description 1',
rule: {
action: PermissionAction.MANAGE,
subject: CommentEntity,
},
});
const permission2 = await permissionRepository.save({
name: 'Manager Test Permission 2',
label: 'manager.test.permission.2',
description: 'Manager test permission description 2',
rule: {
action: PermissionAction.MANAGE,
subject: CommentEntity,
},
});
const permission3 = await permissionRepository.save({
name: 'Manager Test Permission 3',
label: 'manager.test.permission.3',
rule: {
action: PermissionAction.MANAGE,
subject: CommentEntity,
},
});
testPermissions = [permission1, permission2, permission3];
}
async function cleanupTestData() {
if (testPermissions && testPermissions.length > 0) {
await permissionRepository.remove(testPermissions);
}
if (adminUser) {
await userRepository.remove(adminUser);
}
}
describe('GET /permissions', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions`,
});
expect(result.statusCode).toBe(401);
});
it('should return paginated permissions with authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should handle pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions?page=1&limit=5`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should fail with invalid token', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions`,
headers: {
authorization: 'Bearer invalid-token',
},
});
expect(result.statusCode).toBe(401);
});
it('should handle invalid pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions?page=0&limit=0`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(400);
});
});
describe('GET /permissions/:id', () => {
it('should require authentication', async () => {
const permission = testPermissions[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions/${permission.id}`,
});
expect(result.statusCode).toBe(401);
});
it('should return permission detail with authentication', async () => {
const permission = testPermissions[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions/${permission.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const permissionDetail = result.json();
expect(permissionDetail.id).toBe(permission.id);
expect(permissionDetail.name).toBe(permission.name);
expect(permissionDetail.label).toBe(permission.label);
expect(permissionDetail.description).toBe(permission.description);
});
it('should return permission without description if not set', async () => {
const permissionWithoutDesc = testPermissions.find((p) => !p.description);
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions/${permissionWithoutDesc.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const permissionDetail = result.json();
expect(permissionDetail.id).toBe(permissionWithoutDesc.id);
expect(permissionDetail.name).toBe(permissionWithoutDesc.name);
expect(permissionDetail.label).toBe(permissionWithoutDesc.label);
});
it('should fail with invalid UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions/invalid-uuid`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent permission ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/permissions/74e655b3-b69a-42ae-a101-41c224386e74`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(404);
});
});
});
@@ -0,0 +1,589 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { CategoryEntity, PostEntity, TagEntity } from '@/modules/content/entities';
import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories';
import { PostService } from '@/modules/content/services/post.service';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/manager/content';
describe('PostController (Manager)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let postRepository: PostRepository;
let categoryRepository: CategoryRepository;
let tagRepository: TagRepository;
let userRepository: UserRepository;
let testPosts: PostEntity[];
let testCategory: CategoryEntity;
let testTags: TagEntity[];
let adminUser: UserEntity;
let authToken: string;
let postService: PostService;
let permissionRepository: PermissionRepository;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
postRepository = app.get<PostRepository>(PostRepository);
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
tagRepository = app.get<TagRepository>(TagRepository);
userRepository = app.get<UserRepository>(UserRepository);
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
postService = app.get<PostService>(PostService);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建管理员用户
const permission = await permissionRepository.findOneOrFail({
where: { name: 'post.manage' },
});
adminUser = await userRepository.save({
username: 'admin_post',
nickname: 'Admin Post',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'admin_post',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试分类
testCategory = await categoryRepository.save({
name: 'Manager Test Post Category',
customOrder: 1,
});
// 创建测试标签
const tag1 = await tagRepository.save({
name: 'Manager Test Post Tag 1',
desc: 'Manager test tag for posts',
});
const tag2 = await tagRepository.save({
name: 'Manager Test Post Tag 2',
desc: 'Another manager test tag for posts',
});
testTags = [tag1, tag2];
// 创建测试文章
const publishedPost = await postService.create(
{
title: 'Manager Published Test Post',
body: 'This is a manager published test post content.',
summary: 'Manager published test post summary',
category: testCategory.id,
tags: [tag1.id],
publish: true,
},
adminUser,
);
const draftPost = await postService.create(
{
title: 'Manager Draft Test Post',
body: 'This is a manager draft test post content.',
summary: 'Manager draft test post summary',
category: testCategory.id,
tags: [tag2.id],
},
adminUser,
);
testPosts = [publishedPost, draftPost];
}
async function cleanupTestData() {
if (testPosts && testPosts.length > 0) {
await postRepository.remove(testPosts);
}
if (testTags && testTags.length > 0) {
await tagRepository.remove(testTags);
}
if (testCategory) {
await categoryRepository.remove(testCategory);
}
if (adminUser) {
await userRepository.remove(adminUser);
}
}
describe('GET /posts', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts`,
});
expect(result.statusCode).toBe(401);
});
it('should return all posts including drafts', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
// 应该包含已发布和草稿文章
const publishedPosts = response.items.filter((post: any) => post.publishedAt !== null);
const draftPosts = response.items.filter((post: any) => post.publishedAt === null);
expect(publishedPosts.length).toBeGreaterThanOrEqual(0);
expect(draftPosts.length).toBeGreaterThanOrEqual(0);
});
it('should handle pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts?page=1&limit=5`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should filter posts by category', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts?category=${testCategory.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
response.items.forEach((post: any) => {
expect(post.category.id).toBe(testCategory.id);
});
});
it('should search posts by keyword', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts?search=Manager Published`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
const foundPost = response.items.find(
(post: any) =>
post.title.includes('Manager Published') ||
post.body.includes('Manager Published'),
);
expect(foundPost).toBeDefined();
});
});
describe('GET /posts/:id', () => {
it('should require authentication', async () => {
const post = testPosts[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/${post.id}`,
});
expect(result.statusCode).toBe(401);
});
it('should return post detail including drafts', async () => {
const draftPost = testPosts.find((p) => p.publishedAt === null);
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/${draftPost.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const postDetail = result.json();
expect(postDetail.id).toBe(draftPost.id);
expect(postDetail.title).toBe(draftPost.title);
expect(postDetail.publishedAt).toBeNull();
});
it('should fail with invalid UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/invalid-uuid`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent post ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/74e655b3-b69a-42ae-a101-41c224386e74`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(404);
});
});
describe('POST /posts', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Manager Test Post',
body: 'New manager test post content',
},
});
expect(result.statusCode).toBe(401);
});
it('should create post successfully', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'New Manager Test Post',
body: 'New manager test post content',
summary: 'New manager test post summary',
category: testCategory.id,
tags: [testTags[0].id],
},
});
expect(result.statusCode).toBe(201);
const newPost = result.json();
expect(newPost.title).toBe('New Manager Test Post');
expect(newPost.author.id).toBe(adminUser.id);
// 清理创建的文章
await postRepository.delete(newPost.id);
});
it('should create published post', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'New Published Manager Post',
body: 'New published manager post content',
summary: 'New published manager post summary',
category: testCategory.id,
tags: [testTags[0].id],
publish: true,
},
});
expect(result.statusCode).toBe(201);
const newPost = result.json();
expect(newPost.title).toBe('New Published Manager Post');
expect(newPost.publishedAt).not.toBeNull();
// 清理创建的文章
await postRepository.delete(newPost.id);
});
it('should fail with missing required fields', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'New Manager Test Post',
// missing body
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The content of the article must be filled in.',
);
});
it('should fail with invalid category ID', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'New Manager Test Post',
body: 'New manager test post content',
category: 'invalid-uuid',
},
});
expect(result.statusCode).toBe(400);
});
});
describe('PATCH /posts', () => {
it('should require authentication', async () => {
const post = testPosts[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/posts`,
body: {
id: post.id,
title: 'Updated Post Title',
},
});
expect(result.statusCode).toBe(401);
});
it('should update post successfully', async () => {
const post = testPosts[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: post.id,
title: 'Updated Manager Post Title',
body: 'Updated manager post content',
},
});
expect(result.statusCode).toBe(200);
const updatedPost = result.json();
expect(updatedPost.title).toBe('Updated Manager Post Title');
expect(updatedPost.body).toBe('Updated manager post content');
});
it('should publish draft post', async () => {
const draftPost = testPosts.find((p) => p.publishedAt === null);
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: draftPost.id,
publish: true,
},
});
expect(result.statusCode).toBe(200);
const updatedPost = result.json();
expect(updatedPost.publishedAt).not.toBeNull();
});
it('should fail with missing ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'Updated Post',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The article ID must be specified');
});
it('should fail with non-existent ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: '74e655b3-b69a-42ae-a101-41c224386e74',
title: 'Updated Post',
},
});
expect(result.statusCode).toBe(400);
});
});
describe('DELETE /posts', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/posts`,
body: {
ids: [testPosts[0].id],
},
});
expect(result.statusCode).toBe(401);
});
it('should delete posts successfully', async () => {
// 创建临时文章用于删除测试
const tempPost = await postRepository.save({
title: 'Temp Post for Delete',
body: 'Temporary post for deletion test',
author: adminUser,
category: testCategory,
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempPost.id],
},
});
expect(result.statusCode).toBe(200);
// 验证文章已被删除
const deletedPost = await postRepository.findOne({ where: { id: tempPost.id } });
expect(deletedPost).toBeNull();
});
it('should delete multiple posts successfully', async () => {
// 创建多个临时文章用于删除测试
const tempPost1 = await postRepository.save({
title: 'Temp Post 1 for Delete',
body: 'Temporary post 1 for deletion test',
author: adminUser,
category: testCategory,
});
const tempPost2 = await postRepository.save({
title: 'Temp Post 2 for Delete',
body: 'Temporary post 2 for deletion test',
author: adminUser,
category: testCategory,
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempPost1.id, tempPost2.id],
},
});
expect(result.statusCode).toBe(200);
// 验证文章已被删除
const deletedPost1 = await postRepository.findOne({ where: { id: tempPost1.id } });
const deletedPost2 = await postRepository.findOne({ where: { id: tempPost2.id } });
expect(deletedPost1).toBeNull();
expect(deletedPost2).toBeNull();
});
it('should fail with missing ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {},
});
expect(result.statusCode).toBe(400);
});
it('should fail with empty ids array', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [],
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with invalid UUID in ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: ['invalid-uuid'],
},
});
expect(result.statusCode).toBe(400);
});
});
});
@@ -0,0 +1,407 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { RoleEntity } from '@/modules/rbac/entities';
import { PermissionRepository, RoleRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/manager/rbac';
describe('RoleController (Manager)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let roleRepository: RoleRepository;
let userRepository: UserRepository;
let testRoles: RoleEntity[];
let adminUser: UserEntity;
let authToken: string;
let permissionRepository: PermissionRepository;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
roleRepository = app.get<RoleRepository>(RoleRepository);
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
userRepository = app.get<UserRepository>(UserRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建管理员用户
const permission = await permissionRepository.findOneOrFail({
where: { name: 'role.manage' },
});
adminUser = await userRepository.save({
username: 'admin_role_manager',
nickname: 'Admin Role Manager',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'admin_role_manager',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试角色数据
const role1 = await roleRepository.save({
name: 'Manager Test Role 1',
label: 'manager-test-role-1',
description: 'Manager test role description 1',
});
const role2 = await roleRepository.save({
name: 'Manager Test Role 2',
label: 'manager-test-role-2',
description: 'Manager test role description 2',
});
const role3 = await roleRepository.save({
name: 'Manager Test Role 3',
label: 'manager-test-role-3',
});
testRoles = [role1, role2, role3];
}
async function cleanupTestData() {
if (testRoles && testRoles.length > 0) {
await roleRepository.remove(testRoles);
}
if (adminUser) {
await userRepository.remove(adminUser);
}
}
describe('POST /roles', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/roles`,
body: {
name: 'New Test Role',
label: 'new-test-role',
},
});
expect(result.statusCode).toBe(401);
});
it('should create role successfully', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'New Manager Test Role',
label: 'new-manager-test-role',
},
});
expect(result.statusCode).toBe(201);
const newRole = result.json();
expect(newRole.name).toBe('New Manager Test Role');
expect(newRole.label).toBe('new-manager-test-role');
// 清理创建的角色
await roleRepository.delete(newRole.id);
});
it('should create role without description', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Role Without Description',
label: 'role-without-description',
},
});
expect(result.statusCode).toBe(201);
const newRole = result.json();
expect(newRole.name).toBe('Role Without Description');
expect(newRole.label).toBe('role-without-description');
// 清理创建的角色
await roleRepository.delete(newRole.id);
});
it('should fail with missing name', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
label: 'test-role-label',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('名称必须填写');
expect(result.json().message).toContain('名称长度最大为100');
});
it('should success with missing label', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Test Role Name',
},
});
expect(result.statusCode).toBe(201);
});
it('should success with duplicate name', async () => {
const existingRole = testRoles[0];
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: existingRole.name,
label: 'different-label',
},
});
expect(result.statusCode).toBe(201);
});
});
describe('PATCH /roles', () => {
it('should require authentication', async () => {
const role = testRoles[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/roles`,
body: {
id: role.id,
name: 'Updated Role Name',
},
});
expect(result.statusCode).toBe(401);
});
it('should update role successfully', async () => {
const role = testRoles[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: role.id,
name: 'Updated Manager Role',
},
});
expect(result.statusCode).toBe(200);
const updatedRole = result.json();
expect(updatedRole.name).toBe('Updated Manager Role');
});
it('should fail with missing ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Updated Role',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('ID必须指定');
});
it('should fail with invalid ID format', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: 'invalid-uuid',
name: 'Updated Role',
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: '74e655b3-b69a-42ae-a101-41c224386e74',
name: 'Updated Role',
},
});
expect(result.statusCode).toBe(404);
});
});
describe('DELETE /roles', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/roles`,
body: {
ids: [testRoles[0].id],
},
});
expect(result.statusCode).toBe(401);
});
it('should delete roles successfully', async () => {
// 创建临时角色用于删除测试
const tempRole = await roleRepository.save({
name: 'Temp Role for Delete',
label: 'temp-role-for-delete',
description: 'Temporary role for deletion test',
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempRole.id],
},
});
expect(result.statusCode).toBe(200);
// 验证角色已被删除
const deletedRole = await roleRepository.findOne({ where: { id: tempRole.id } });
expect(deletedRole).toBeNull();
});
it('should delete multiple roles successfully', async () => {
// 创建多个临时角色用于删除测试
const tempRole1 = await roleRepository.save({
name: 'Temp Role 1 for Delete',
label: 'temp-role-1-for-delete',
});
const tempRole2 = await roleRepository.save({
name: 'Temp Role 2 for Delete',
label: 'temp-role-2-for-delete',
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempRole1.id, tempRole2.id],
},
});
expect(result.statusCode).toBe(200);
// 验证角色已被删除
const deletedRole1 = await roleRepository.findOne({ where: { id: tempRole1.id } });
const deletedRole2 = await roleRepository.findOne({ where: { id: tempRole2.id } });
expect(deletedRole1).toBeNull();
expect(deletedRole2).toBeNull();
});
it('should fail with missing ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {},
});
expect(result.statusCode).toBe(400);
});
it('should fail with empty ids array', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [],
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with invalid UUID in ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/roles`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: ['invalid-uuid'],
},
});
expect(result.statusCode).toBe(400);
});
});
});
@@ -0,0 +1,569 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { TagEntity } from '@/modules/content/entities';
import { TagRepository } from '@/modules/content/repositories';
import { getRandomString } from '@/modules/core/helpers';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/manager/content';
describe('TagController (Manager)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let tagRepository: TagRepository;
let userRepository: UserRepository;
let testTags: TagEntity[];
let adminUser: UserEntity;
let authToken: string;
let permissionRepository: PermissionRepository;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
tagRepository = app.get<TagRepository>(TagRepository);
userRepository = app.get<UserRepository>(UserRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建管理员用户
const permission = await permissionRepository.findOneOrFail({
where: { name: 'tag.manage' },
});
adminUser = await userRepository.save({
username: 'admin_tag',
nickname: 'Admin Tag',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'admin_tag',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试标签数据
const tag1 = await tagRepository.save({
name: 'Manager Test Tag 1',
desc: 'Manager test tag description 1',
});
const tag2 = await tagRepository.save({
name: 'Manager Test Tag 2',
desc: 'Manager test tag description 2',
});
const tag3 = await tagRepository.save({
name: 'Manager Test Tag 3',
});
testTags = [tag1, tag2, tag3];
}
async function cleanupTestData() {
if (testTags && testTags.length > 0) {
await tagRepository.remove(testTags);
}
if (adminUser) {
await userRepository.remove(adminUser);
}
}
describe('GET /tag', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag`,
});
expect(result.statusCode).toBe(401);
});
it('should return paginated tags with authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should handle pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?page=1&limit=5`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should fail with invalid token', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: 'Bearer invalid-token',
},
});
expect(result.statusCode).toBe(401);
});
});
describe('GET /tag/:id', () => {
it('should require authentication', async () => {
const tag = testTags[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/${tag.id}`,
});
expect(result.statusCode).toBe(401);
});
it('should return tag detail with authentication', async () => {
const tag = testTags[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/${tag.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const tagDetail = result.json();
expect(tagDetail.id).toBe(tag.id);
expect(tagDetail.name).toBe(tag.name);
expect(tagDetail.desc).toBe(tag.desc);
});
it('should fail with invalid UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/invalid-uuid`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(400);
});
});
describe('POST /tag', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
body: {
name: 'New Test Tag',
},
});
expect(result.statusCode).toBe(401);
});
it('should create tag successfully', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'New Manager Test Tag',
desc: 'New manager test tag description',
},
});
expect(result.statusCode).toBe(201);
const newTag = result.json();
expect(newTag.name).toBe('New Manager Test Tag');
expect(newTag.desc).toBe('New manager test tag description');
// 清理创建的标签
await tagRepository.delete(newTag.id);
});
it('should create tag without description', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Tag Without Description',
},
});
expect(result.statusCode).toBe(201);
const newTag = result.json();
expect(newTag.name).toBe('Tag Without Description');
// 清理创建的标签
await tagRepository.delete(newTag.id);
});
it('should fail with missing name', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
desc: 'Tag description without name',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The classification name cannot be empty');
});
it('should fail with duplicate name', async () => {
const existingTag = testTags[0];
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: existingTag.name,
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The label names are repeated');
});
it('should fail with too long name', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'A'.repeat(256), // 超过255字符限制
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The maximum length of the label name is 255');
});
it('should fail with too long description', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Valid Tag Name',
desc: 'A'.repeat(501), // 超过500字符限制
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The maximum length of the label description is 500',
);
});
it('should fail with empty name', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: '',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The classification name cannot be empty');
});
it('should fail with whitespace only name', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: ' ',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The classification name cannot be empty');
});
});
describe('PATCH /tag', () => {
it('should require authentication', async () => {
const tag = testTags[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/tag`,
body: {
id: tag.id,
name: 'Updated Tag Name',
},
});
expect(result.statusCode).toBe(401);
});
it('should update tag successfully', async () => {
const tag = testTags[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: tag.id,
name: 'Updated Manager Tag',
desc: 'Updated manager tag description',
},
});
expect(result.statusCode).toBe(200);
const updatedTag = result.json();
expect(updatedTag.name).toBe('Updated Manager Tag');
expect(updatedTag.desc).toBe('Updated manager tag description');
});
it('should fail with missing ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
name: 'Updated Tag',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The ID must be specified');
});
it('should fail with invalid ID format', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: 'invalid-uuid',
name: 'Updated Tag',
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: '74e655b3-b69a-42ae-a101-41c224386e74',
name: 'Updated Tag',
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with duplicate name', async () => {
const [tag1, tag2] = testTags;
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: tag1.id,
name: tag2.name, // 使用另一个标签的名称
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The label names are repeated');
});
});
describe('DELETE /tag', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/tag`,
body: {
ids: [testTags[0].id],
},
});
expect(result.statusCode).toBe(401);
});
it('should delete tags successfully', async () => {
// 创建临时标签用于删除测试
const tag = getRandomString();
const tempTag = await tagRepository.save({
name: `Temp Tag for Delete${tag}`,
desc: 'Temporary tag for deletion test',
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempTag.id],
},
});
expect(result.statusCode).toBe(200);
// 验证标签已被删除
const deletedTag = await tagRepository.findOne({ where: { id: tempTag.id } });
expect(deletedTag).toBeNull();
});
it('should delete multiple tags successfully', async () => {
// 创建多个临时标签用于删除测试
const tag = getRandomString();
const tempTag1 = await tagRepository.save({
name: `Temp Tag 1 for Delete ${tag}`,
});
const tempTag2 = await tagRepository.save({
name: `Temp Tag 2 for Delete ${tag}`,
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempTag1.id, tempTag2.id],
},
});
expect(result.statusCode).toBe(200);
// 验证标签已被删除
const deletedTag1 = await tagRepository.findOne({ where: { id: tempTag1.id } });
const deletedTag2 = await tagRepository.findOne({ where: { id: tempTag2.id } });
expect(deletedTag1).toBeNull();
expect(deletedTag2).toBeNull();
});
it('should fail with missing ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {},
});
expect(result.statusCode).toBe(400);
});
it('should fail with empty ids array', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [],
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with invalid UUID in ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/tag`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: ['invalid-uuid'],
},
});
expect(result.statusCode).toBe(400);
});
});
});
@@ -0,0 +1,547 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/manager/manager';
describe('UserController (Manager)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let userRepository: UserRepository;
let testUsers: UserEntity[];
let adminUser: UserEntity;
let permissionRepository: PermissionRepository;
let authToken: string;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
userRepository = app.get<UserRepository>(UserRepository);
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建管理员用户
const permission = await permissionRepository.findOneOrFail({
where: { name: 'user.manage' },
});
adminUser = await userRepository.save({
username: 'admin_user_manager',
nickname: 'Admin User Manager',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: 'admin_user_manager',
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试用户数据
const user1 = await userRepository.save({
username: 'manager_testuser1',
nickname: 'Manager Test User 1',
password: 'password123',
email: 'manager_testuser1@example.com',
});
const user2 = await userRepository.save({
username: 'manager_testuser2',
nickname: 'Manager Test User 2',
password: 'password123',
email: 'manager_testuser2@example.com',
});
const user3 = await userRepository.save({
username: 'manager_testuser3',
nickname: 'Manager Test User 3',
password: 'password123',
});
testUsers = [user1, user2, user3];
}
async function cleanupTestData() {
if (testUsers && testUsers.length > 0) {
await userRepository.remove(testUsers);
}
if (adminUser) {
await userRepository.remove(adminUser);
}
}
describe('GET /users', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users`,
});
expect(result.statusCode).toBe(401);
});
it('should return paginated users with authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should handle pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?page=1&limit=5`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should fail with invalid token', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users`,
headers: {
authorization: 'Bearer invalid-token',
},
});
expect(result.statusCode).toBe(401);
});
});
describe('GET /users/:id', () => {
it('should require authentication', async () => {
const user = testUsers[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/${user.id}`,
});
expect(result.statusCode).toBe(401);
});
it('should return user detail with authentication', async () => {
const user = testUsers[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/${user.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const userDetail = result.json();
expect(userDetail.id).toBe(user.id);
expect(userDetail.username).toBe(user.username);
expect(userDetail.nickname).toBe(user.nickname);
// 管理员应该能看到敏感信息
expect(userDetail.email).toBe(user.email);
});
it('should fail with invalid UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/invalid-uuid`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent user ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/74e65577-b69a-42ae-a101-41c224386e78`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(404);
});
});
describe('POST /users', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/users`,
body: {
username: 'newmanageruser',
nickname: 'New Manager User',
password: 'password123',
},
});
expect(result.statusCode).toBe(401);
});
it('should create user successfully', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
username: 'newmanageruser123',
nickname: 'New Manager User',
password: 'password123',
email: 'newmanageruser@example.com',
},
});
expect(result.statusCode).toBe(201);
const newUser = result.json();
expect(newUser.username).toBe('newmanageruser123');
expect(newUser.nickname).toBe('New Manager User');
expect(newUser.email).toBe('newmanageruser@example.com');
// 不应该返回密码
expect(newUser.password).toBeUndefined();
// 清理创建的用户
await userRepository.delete(newUser.id);
});
it('should fail with missing required fields', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
// missing username and password
nickname: 'Test User',
},
});
expect(result.statusCode).toBe(400);
const response = result.json();
expect(response.message).toContain('用户名长度必须为4到30');
expect(response.message).toContain('密码长度不得少于8');
});
it('should fail with duplicate username', async () => {
const existingUser = testUsers[0];
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
username: existingUser.username,
nickname: 'Another User',
password: 'password123',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('该用户名已被注册');
});
it('should fail with invalid email format', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
username: 'testuser_email_manager',
nickname: 'Test User',
password: 'password123',
email: 'invalid-email',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('邮箱地址格式错误');
});
});
describe('PATCH /users', () => {
it('should require authentication', async () => {
const user = testUsers[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/users`,
body: {
id: user.id,
nickname: 'Updated Nickname',
},
});
expect(result.statusCode).toBe(401);
});
it('should update user successfully', async () => {
const user = testUsers[0];
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: user.id,
nickname: 'Updated Manager User',
email: 'updated@example.com',
},
});
expect(result.statusCode).toBe(200);
const updatedUser = result.json();
expect(updatedUser.nickname).toBe('Updated Manager User');
expect(updatedUser.email).toBe('updated@example.com');
});
it('should fail with missing ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
nickname: 'Updated User',
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('用户ID格式不正确');
});
it('should fail with invalid ID format', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: 'invalid-uuid',
nickname: 'Updated User',
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent ID', async () => {
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: '88e677b3-b88a-42ae-a101-88c224386e88',
nickname: 'Updated User',
},
});
expect(result.statusCode).toBe(200);
});
it('should fail with duplicate username', async () => {
const [user1, user2] = testUsers;
const result = await app.inject({
method: 'PATCH',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
id: user1.id,
username: user2.username, // 使用另一个用户的用户名
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('该用户名已被注册');
});
});
describe('DELETE /users', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/users`,
body: {
ids: [testUsers[0].id],
},
});
expect(result.statusCode).toBe(401);
});
it('should delete users successfully', async () => {
// 创建临时用户用于删除测试
const tempUser = await userRepository.save({
username: 'temp_user_for_delete',
nickname: 'Temp User for Delete',
password: 'password123',
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempUser.id],
},
});
expect(result.statusCode).toBe(200);
// 验证用户已被删除
const deletedUser = await userRepository.findOne({ where: { id: tempUser.id } });
expect(deletedUser).toBeNull();
});
it('should delete multiple users successfully', async () => {
// 创建多个临时用户用于删除测试
const tempUser1 = await userRepository.save({
username: 'temp_user1_for_delete',
nickname: 'Temp User 1 for Delete',
password: 'password123',
});
const tempUser2 = await userRepository.save({
username: 'temp_user2_for_delete',
nickname: 'Temp User 2 for Delete',
password: 'password123',
});
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [tempUser1.id, tempUser2.id],
},
});
expect(result.statusCode).toBe(200);
// 验证用户已被删除
const deletedUser1 = await userRepository.findOne({ where: { id: tempUser1.id } });
const deletedUser2 = await userRepository.findOne({ where: { id: tempUser2.id } });
expect(deletedUser1).toBeNull();
expect(deletedUser2).toBeNull();
});
it('should fail with missing ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {},
});
expect(result.statusCode).toBe(400);
});
it('should fail with empty ids array', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [],
},
});
expect(result.statusCode).toBe(400);
});
it('should fail with invalid UUID in ids', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: ['invalid-uuid'],
},
});
expect(result.statusCode).toBe(400);
});
it('should not delete admin user (self-protection)', async () => {
const result = await app.inject({
method: 'DELETE',
url: `${URL_PREFIX}/users`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
ids: [adminUser.id],
},
});
// 应该失败或者有特殊处理,防止管理员删除自己
expect([400, 403, 422]).toContain(result.statusCode);
});
});
});
+428
View File
@@ -0,0 +1,428 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { CategoryEntity, PostEntity, TagEntity } from '@/modules/content/entities';
import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories';
import { PostService } from '@/modules/content/services/post.service';
import { getRandomString } from '@/modules/core/helpers';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { PermissionRepository } from '@/modules/rbac/repositories';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/content';
describe('PostController (App)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let postRepository: PostRepository;
let categoryRepository: CategoryRepository;
let tagRepository: TagRepository;
let userRepository: UserRepository;
let testPosts: PostEntity[];
let testCategory: CategoryEntity;
let testTags: TagEntity[];
let testUser: UserEntity;
let authToken: string;
const cleanData = true;
let postService: PostService;
let permissionRepository: PermissionRepository;
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
postRepository = app.get<PostRepository>(PostRepository);
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
tagRepository = app.get<TagRepository>(TagRepository);
userRepository = app.get<UserRepository>(UserRepository);
postService = app.get<PostService>(PostService);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
const tag = cleanData ? '' : getRandomString();
const permission = await permissionRepository.findOneOrFail({
where: { name: 'post.create' },
});
// 创建测试用户
testUser = await userRepository.save({
username: `test_user_post_${tag}`,
nickname: 'Test User Post',
password: 'password123',
permissions: [permission],
});
// 获取认证token
const loginResult = await app.inject({
method: 'POST',
url: '/api/v1/user/account/login',
body: {
credential: `test_user_post_${tag}`,
password: 'password123',
},
});
authToken = loginResult.json().token;
// 创建测试分类
testCategory = await categoryRepository.save({
name: `Test Post Category ${tag}`,
customOrder: 1,
});
// 创建测试标签
const tag1 = await tagRepository.save({
name: `Test Post Tag 1 ${tag}`,
desc: 'Test tag for posts',
});
const tag2 = await tagRepository.save({
name: `Test Post Tag 2 ${tag}`,
desc: 'Another test tag for posts',
});
testTags = [tag1, tag2];
// 创建测试文章
const publishedPost = await postService.create(
{
title: 'Published Test Post',
body: 'This is a published test post content.',
summary: 'Published test post summary',
category: testCategory.id,
tags: [tag1.id],
publish: true,
},
testUser,
);
const draftPost = await postService.create(
{
title: 'Draft Test Post',
body: 'This is a draft test post content.',
summary: 'Draft test post summary',
category: testCategory.id,
tags: [tag2.id],
},
testUser,
);
testPosts = [publishedPost, draftPost];
}
async function cleanupTestData() {
if (!cleanData) {
return;
}
if (testPosts && testPosts.length > 0) {
await postRepository.remove(testPosts);
}
if (testTags && testTags.length > 0) {
await tagRepository.remove(testTags);
}
if (testCategory) {
await categoryRepository.remove(testCategory);
}
if (testUser) {
await userRepository.remove(testUser);
}
}
describe('GET /posts', () => {
it('should return published posts only', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
// 应该只返回已发布的文章
response.items.forEach((post: any) => {
expect(post.publishedAt).not.toBeNull();
});
});
it('should return posts with pagination', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts?page=1&limit=5`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should filter posts by category', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts?category=${testCategory.id}`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
response.items.forEach((post: any) => {
expect(post.category.id).toBe(testCategory.id);
});
});
it('should search posts by keyword', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts?search=Published`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
// 搜索结果应该包含关键词
const foundPost = response.items.find(
(post: any) => post.title.includes('Published') || post.body.includes('Published'),
);
expect(foundPost).toBeDefined();
});
it('should fail with invalid pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts?page=0&limit=0`,
});
expect(result.statusCode).toBe(400);
});
});
describe('GET /posts/owner', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/owner`,
});
expect(result.statusCode).toBe(401);
});
it('should return user own posts with authentication', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/owner`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
// 应该只返回当前用户的文章
response.items.forEach((post: any) => {
expect(post.author.id).toBe(testUser.id);
});
});
it('should return both published and draft posts for owner', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/owner`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const response = result.json();
// 应该包含已发布和草稿文章
const publishedPosts = response.items.filter((post: any) => post.publishedAt !== null);
const draftPosts = response.items.filter((post: any) => post.publishedAt === null);
expect(publishedPosts.length).toBeGreaterThan(0);
expect(draftPosts.length).toBeGreaterThan(0);
});
});
describe('GET /posts/:id', () => {
it('should return published post detail', async () => {
const publishedPost = testPosts.find((p) => p.publishedAt !== null);
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/${publishedPost.id}`,
});
expect(result.statusCode).toBe(200);
const postDetail = result.json();
expect(postDetail.id).toBe(publishedPost.id);
expect(postDetail.title).toBe(publishedPost.title);
expect(postDetail.publishedAt).not.toBeNull();
});
it('should not return draft post to public', async () => {
const draftPost = testPosts.find((p) => p.publishedAt === null);
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/${draftPost.id}`,
});
expect(result.statusCode).toBe(404);
});
it('should fail with invalid UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/invalid-uuid`,
});
expect(result.statusCode).toBe(400);
});
it('should fail with non-existent post ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/74e655b3-b69a-42ae-a101-41c224386e74`,
});
expect(result.statusCode).toBe(404);
});
});
describe('GET /posts/owner/:id', () => {
it('should require authentication', async () => {
const post = testPosts[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/owner/${post.id}`,
});
expect(result.statusCode).toBe(401);
});
it('should return owner post detail including drafts', async () => {
const draftPost = testPosts.find((p) => p.publishedAt === null);
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/owner/${draftPost.id}`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(200);
const postDetail = result.json();
expect(postDetail.id).toBe(draftPost.id);
expect(postDetail.author.id).toBe(testUser.id);
});
it('should fail when accessing other user post', async () => {
// 这里需要创建另一个用户的文章来测试权限
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/posts/owner/74e655b3-b69a-42ae-a101-41c224386e74`,
headers: {
authorization: `Bearer ${authToken}`,
},
});
expect(result.statusCode).toBe(404);
});
});
describe('POST /posts', () => {
it('should require authentication', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
body: {
title: 'New Test Post',
body: 'New test post content',
},
});
expect(result.statusCode).toBe(401);
});
it('should create post successfully', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'New Test Post',
body: 'New test post content',
summary: 'New test post summary',
category: testCategory.id,
tags: [testTags[0].id],
},
});
expect(result.statusCode).toBe(201);
const newPost = result.json();
expect(newPost.title).toBe('New Test Post');
expect(newPost.author.id).toBe(testUser.id);
// 清理创建的文章
await postRepository.delete(newPost.id);
});
it('should fail with missing required fields', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'New Test Post',
// missing body
},
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The content of the article must be filled in.',
);
});
it('should fail with invalid category ID', async () => {
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/posts`,
headers: {
authorization: `Bearer ${authToken}`,
},
body: {
title: 'New Test Post',
body: 'New test post content',
category: 'invalid-uuid',
},
});
expect(result.statusCode).toBe(400);
});
});
});
+260
View File
@@ -0,0 +1,260 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { RoleEntity } from '@/modules/rbac/entities';
import { RoleRepository } from '@/modules/rbac/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/rbac';
describe('RoleController (App)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let roleRepository: RoleRepository;
let testRoles: RoleEntity[];
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
roleRepository = app.get<RoleRepository>(RoleRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建测试角色数据
const role1 = await roleRepository.save({
name: 'Test Role 1',
label: 'test-role-1',
description: 'Test role description 1',
});
const role2 = await roleRepository.save({
name: 'Test Role 2',
label: 'test-role-2',
description: 'Test role description 2',
});
const role3 = await roleRepository.save({
name: 'Test Role 3',
label: 'test-role-3',
});
testRoles = [role1, role2, role3];
}
async function cleanupTestData() {
if (testRoles && testRoles.length > 0) {
await roleRepository.remove(testRoles);
}
}
describe('GET /roles', () => {
it('should return paginated roles successfully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should return roles with valid pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles?page=1&limit=5`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should filter roles by trashed status', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles?trashed=none`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(Array.isArray(response.items)).toBe(true);
});
it('should fail with invalid page parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles?page=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The current page must be greater than 1.');
});
it('should fail with invalid limit parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles?limit=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The number of data displayed per page must be greater than 1.',
);
});
it('should handle negative page numbers', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles?page=-1`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The current page must be greater than 1.');
});
it('should handle large page numbers gracefully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles?page=999999`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toEqual([]);
});
it('should return roles with correct structure', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles?limit=1`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
const role = response.items[0];
expect(role.id).toBeDefined();
expect(role.name).toBeDefined();
expect(role.label).toBeDefined();
expect(typeof role.name).toBe('string');
expect(typeof role.label).toBe('string');
});
});
describe('GET /roles/:id', () => {
it('should return role detail successfully', async () => {
const role = testRoles[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/${role.id}`,
});
expect(result.statusCode).toBe(200);
const roleDetail = result.json();
expect(roleDetail.id).toBe(role.id);
expect(roleDetail.name).toBe(role.name);
expect(roleDetail.label).toBe(role.label);
expect(roleDetail.description).toBe(role.description);
});
it('should return role without description if not set', async () => {
const roleWithoutDesc = testRoles.find((r) => !r.description);
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/${roleWithoutDesc.id}`,
});
expect(result.statusCode).toBe(200);
const roleDetail = result.json();
expect(roleDetail.id).toBe(roleWithoutDesc.id);
expect(roleDetail.name).toBe(roleWithoutDesc.name);
expect(roleDetail.label).toBe(roleWithoutDesc.label);
});
it('should fail with invalid UUID format', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/invalid-uuid`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should fail with non-existent role ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/74e655b3-b69a-42ae-a101-41c224386e74`,
});
console.log(result.json());
expect(result.statusCode).toBe(404);
});
it('should handle empty UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/`,
});
expect(result.statusCode).toBe(400);
});
it('should handle malformed UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/not-a-uuid-at-all`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should handle UUID with wrong format', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/12345678-1234-1234-1234-123456789012345`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should return role with permissions if available', async () => {
const role = testRoles[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/roles/${role.id}`,
});
expect(result.statusCode).toBe(200);
const roleDetail = result.json();
// 权限字段应该存在(即使为空)
expect(roleDetail.permissions).toBeDefined();
});
});
});
+240
View File
@@ -0,0 +1,240 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { TagEntity } from '@/modules/content/entities';
import { TagRepository } from '@/modules/content/repositories';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/content';
describe('TagController (App)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let tagRepository: TagRepository;
let testTags: TagEntity[];
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
tagRepository = app.get<TagRepository>(TagRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建测试标签数据
const tag1 = await tagRepository.save({
name: 'Test Tag 1',
desc: 'Test tag description 1',
});
const tag2 = await tagRepository.save({
name: 'Test Tag 2',
desc: 'Test tag description 2',
});
const tag3 = await tagRepository.save({
name: 'Test Tag 3',
});
testTags = [tag1, tag2, tag3];
}
async function cleanupTestData() {
if (testTags && testTags.length > 0) {
await tagRepository.remove(testTags);
}
}
describe('GET /tag', () => {
it('should return paginated tags successfully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should return tags with valid pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?page=1&limit=5`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should fail with invalid page parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?page=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The current page must be greater than 1.');
});
it('should fail with invalid limit parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?limit=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The number of data displayed per page must be greater than 1.');
});
it('should handle negative page numbers', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?page=-1`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The current page must be greater than 1.');
});
it('should handle negative limit values', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?limit=-5`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The number of data displayed per page must be greater than 1.');
});
it('should handle large page numbers gracefully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?page=999999`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toEqual([]);
});
it('should return tags with correct structure', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag?limit=1`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
if (response.items.length > 0) {
const tag = response.items[0];
expect(tag.id).toBeDefined();
expect(tag.name).toBeDefined();
expect(typeof tag.name).toBe('string');
}
});
});
describe('GET /tag/:id', () => {
it('should return tag detail successfully', async () => {
const tag = testTags[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/${tag.id}`,
});
expect(result.statusCode).toBe(200);
const tagDetail = result.json();
expect(tagDetail.id).toBe(tag.id);
expect(tagDetail.name).toBe(tag.name);
expect(tagDetail.desc).toBe(tag.desc);
});
it('should return tag without description if not set', async () => {
const tagWithoutDesc = testTags.find(t => !t.desc);
if (tagWithoutDesc) {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/${tagWithoutDesc.id}`,
});
expect(result.statusCode).toBe(200);
const tagDetail = result.json();
expect(tagDetail.id).toBe(tagWithoutDesc.id);
expect(tagDetail.name).toBe(tagWithoutDesc.name);
}
});
it('should fail with invalid UUID format', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/invalid-uuid`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should fail with non-existent tag ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/74e655b3-b69a-42ae-a101-41c224386e74`,
});
expect(result.statusCode).toBe(404);
});
it('should handle empty UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/`,
});
expect(result.statusCode).toBe(400);
});
it('should handle malformed UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/not-a-uuid-at-all`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should handle UUID with wrong format', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/tag/12345678-1234-1234-1234-123456789012345`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
});
});
+253
View File
@@ -0,0 +1,253 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DataSource } from 'typeorm';
import { createApp } from '@/modules/core/helpers/app';
import { App } from '@/modules/core/types';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { createOptions } from '@/options';
const URL_PREFIX = '/api/v1/user';
describe('UserController (App)', () => {
let datasource: DataSource;
let app: NestFastifyApplication;
let userRepository: UserRepository;
let testUsers: UserEntity[];
beforeAll(async () => {
const appConfig: App = await createApp(createOptions)();
app = appConfig.container;
await app.init();
await app.getHttpAdapter().getInstance().ready();
userRepository = app.get<UserRepository>(UserRepository);
datasource = app.get<DataSource>(DataSource);
if (!datasource.isInitialized) {
await datasource.initialize();
}
// 创建测试数据
await setupTestData();
});
afterAll(async () => {
// 清理测试数据
await cleanupTestData();
await datasource.destroy();
await app.close();
});
async function setupTestData() {
// 创建测试用户数据
const user1 = await userRepository.save({
username: 'testuser1',
nickname: 'Test User 1',
password: 'password123',
email: 'testuser1@example.com',
});
const user2 = await userRepository.save({
username: 'testuser2',
nickname: 'Test User 2',
password: 'password123',
email: 'testuser2@example.com',
});
const user3 = await userRepository.save({
username: 'testuser3',
nickname: 'Test User 3',
password: 'password123',
});
testUsers = [user1, user2, user3];
}
async function cleanupTestData() {
if (testUsers && testUsers.length > 0) {
await userRepository.remove(testUsers);
}
}
describe('GET /users', () => {
it('should return paginated users successfully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toBeDefined();
expect(response.meta).toBeDefined();
expect(Array.isArray(response.items)).toBe(true);
});
it('should return users with valid pagination parameters', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?page=1&limit=5`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.meta.currentPage).toBe(1);
expect(response.meta.perPage).toBe(5);
});
it('should fail with invalid page parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?page=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The current page must be greater than 1.');
});
it('should fail with invalid limit parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?limit=0`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain(
'The number of data displayed per page must be greater than 1.',
);
});
it('should handle negative page numbers', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?page=-1`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('The current page must be greater than 1.');
});
it('should handle large page numbers gracefully', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?page=999999`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(response.items).toEqual([]);
});
it('should return users without sensitive information', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?limit=1`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
if (response.items.length > 0) {
const user = response.items[0];
expect(user.id).toBeDefined();
expect(user.username).toBeDefined();
expect(user.nickname).toBeDefined();
// 不应该包含敏感信息
expect(user.password).toBeUndefined();
}
});
it('should filter users by order parameter', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users?orderBy=createdAt`,
});
expect(result.statusCode).toBe(200);
const response = result.json();
expect(Array.isArray(response.items)).toBe(true);
});
});
describe('GET /users/:id', () => {
it('should return user detail successfully', async () => {
const user = testUsers[0];
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/${user.id}`,
});
expect(result.statusCode).toBe(200);
const userDetail = result.json();
expect(userDetail.id).toBe(user.id);
expect(userDetail.username).toBe(user.username);
expect(userDetail.nickname).toBe(user.nickname);
// 不应该包含敏感信息
expect(userDetail.password).toBeUndefined();
});
it('should return user with email if available', async () => {
const userWithEmail = testUsers.find((u) => u.email);
if (userWithEmail) {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/${userWithEmail.id}`,
});
expect(result.statusCode).toBe(200);
const userDetail = result.json();
expect(userDetail.email).toBe(userWithEmail.email);
}
});
it('should fail with invalid UUID format', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/invalid-uuid`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should fail with non-existent user ID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/74e655b3-b99a-42ae-a101-41c224386e74`,
});
expect(result.statusCode).toBe(404);
});
it('should handle empty UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/`,
});
expect(result.statusCode).toBe(400);
});
it('should handle malformed UUID', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/not-a-uuid-at-all`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
it('should handle UUID with wrong length', async () => {
const result = await app.inject({
method: 'GET',
url: `${URL_PREFIX}/users/12345678-1234-1234-1234-123456789012345`,
});
expect(result.statusCode).toBe(400);
expect(result.json().message).toContain('Validation failed (uuid is expected)');
});
});
});