diff --git a/test/all-case.test.ts b/test/all-case.test.ts deleted file mode 100644 index 92177ae..0000000 --- a/test/all-case.test.ts +++ /dev/null @@ -1,1336 +0,0 @@ -import { describe } from 'node:test'; - -import { NestFastifyApplication } from '@nestjs/platform-fastify'; - -import { isNil, pick } from 'lodash'; -import { DataSource } from 'typeorm'; - -import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities'; -import { - CategoryRepository, - CommentRepository, - PostRepository, - TagRepository, -} from '@/modules/content/repositories'; -import { createApp } from '@/modules/core/helpers/app'; -import { App } from '@/modules/core/types'; -import { MeiliService } from '@/modules/meilisearch/meili.service'; - -import { createOptions } from '@/options'; - -import { generateRandomNumber, generateUniqueRandomNumbers } from './generate-mock-data'; -import { categoriesData, commentData, INIT_DATA, postData, tagData } from './test-data'; - -const URL_PREFIX = '/api/v1/content'; - -describe('nest app test', () => { - let datasource: DataSource; - let app: NestFastifyApplication; - let categoryRepository: CategoryRepository; - let tagRepository: TagRepository; - let postRepository: PostRepository; - let commentRepository: CommentRepository; - - let posts: PostEntity[]; - let categories: CategoryEntity[]; - let tags: TagEntity[]; - let comments: CommentEntity[]; - let searchService: MeiliService; - - beforeAll(async () => { - const appConfig: App = await createApp(createOptions)(); - app = appConfig.container; - await app.init(); - await app.getHttpAdapter().getInstance().ready(); - - categoryRepository = app.get(CategoryRepository); - tagRepository = app.get(TagRepository); - postRepository = app.get(PostRepository); - commentRepository = app.get(CommentRepository); - searchService = app.get(MeiliService); - datasource = app.get(DataSource); - if (!datasource.isInitialized) { - await datasource.initialize(); - } - if (INIT_DATA) { - const client = searchService.getClient(); - client.deleteIndex('content'); - - const queryRunner = datasource.createQueryRunner(); - try { - await queryRunner.query('SET FOREIGN_KEY_CHECKS = 0'); - - datasource.entityMetadatas.map(async (entity) => { - const table = entity.schema - ? `${entity.schema}.${entity.tableName}` - : `${entity.tableName}`; - console.log(`TRUNCATE TABLE ${table}`); - await queryRunner.query(`TRUNCATE TABLE ${table}`); - return table; - }); - } finally { - await queryRunner.query('SET FOREIGN_KEY_CHECKS = 1'); - await queryRunner.release(); - } - - // init category data - categories = await addCategory(app, categoriesData); - const ids = categories.map((item) => item.id); - categories = []; - await Promise.all( - ids.map(async (id) => { - const result = await app.inject({ - method: 'GET', - url: `${URL_PREFIX}/category/${id}`, - }); - categories.push(result.json()); - return result.json(); - }), - ); - - // init tag data - tags = await addTag(app, tagData); - // init post data - posts = await addPost( - app, - postData, - tags.map((tag) => tag.id), - categories.map((category) => category.id), - ); - // init comment data - comments = await addComment( - app, - commentData, - posts.map((post) => post.id), - ); - } - }); - - it('check init', async () => { - expect(app).toBeDefined(); - }); - - describe('category test', () => { - it('repository init', () => { - expect(categoryRepository).toBeDefined(); - }); - it('repository check data', () => { - expect(categories.length).toEqual(13); - }); - - it('create category without name', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: {}, - }); - expect(result.json()).toEqual({ - message: [ - 'The classification name cannot be empty', - 'The length of the category name shall not exceed 25', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with long name', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { name: 'A'.repeat(30) }, - }); - expect(result.json()).toEqual({ - message: ['The length of the category name shall not exceed 25'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with same name at root level', async () => { - const rootCategory = categories.find((c) => !c.parent); - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { name: rootCategory.name }, - }); - expect(result.json()).toEqual({ - message: ['The Category names are duplicated'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with same name under same parent', async () => { - const testData = categories.find((item) => !isNil(item.parent)); - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: testData.name, - parent: testData.parent.id, - }, - }); - expect(result.json()).toEqual({ - message: ['The Category names are duplicated'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with invalid parent id format', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - parent: 'invalid-uuid', - }, - }); - expect(result.json()).toEqual({ - message: [ - 'The format of the parent category ID is incorrect.', - 'The parent category does not exist', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with non-existent parent id', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - parent: '74e655b3-b69a-42ae-a101-41c224386e74', - }, - }); - expect(result.json()).toEqual({ - message: ['The parent category does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with negative custom order', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - customOrder: -1, - }, - }); - expect(result.json()).toEqual({ - message: ['The sorted value must be greater than 0.'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with empty name', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { name: '' }, - }); - expect(result.json()).toEqual({ - message: ['The classification name cannot be empty'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with whitespace name', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { name: ' ' }, - }); - expect(result.json()).toEqual({ - message: ['The classification name cannot be empty'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with name exactly at limit (25 chars)', async () => { - const name = 'A'.repeat(25); - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { name }, - }); - expect(result.statusCode).toEqual(201); - const category: CategoryEntity = result.json(); - expect(category.name).toBe(name); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - it('create category with name one char over limit (26 chars)', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { name: 'A'.repeat(26) }, - }); - expect(result.json()).toEqual({ - message: ['The length of the category name shall not exceed 25'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create root category with duplicate name', async () => { - const rootCategory = categories.find((c) => !c.parent); - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { name: rootCategory.name }, - }); - expect(result.json()).toEqual({ - message: ['The Category names are duplicated'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create child category with duplicate name under same parent', async () => { - const parentCategory = categories.find((c) => c.children.length > 0); - const existingChild = parentCategory.children[0]; - - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: existingChild.name, - parent: parentCategory.id, - }, - }); - expect(result.json()).toEqual({ - message: ['The Category names are duplicated'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create child category with same name but different parent', async () => { - const parent1 = categories.find((c) => c.children.length > 0); - const parent2 = categories.find((c) => c.id !== parent1.id && c.children.length > 0); - const childName = parent1.children[0].name; - - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: childName, - parent: parent2.id, - }, - }); - expect(result.statusCode).toEqual(201); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - it('create category with parent set to null string', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'Root Category', - parent: 'null', // 注意:这里传递字符串 'null' - }, - }); - expect(result.statusCode).toEqual(201); - const category: CategoryEntity = result.json(); - expect(category.parent).toBeNull(); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - it('create category with parent set to null value', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'Root Category', - parent: null, - }, - }); - expect(result.statusCode).toEqual(201); - const category: CategoryEntity = result.json(); - expect(category.parent).toBeNull(); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - it('create category with empty parent id', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - parent: '', - }, - }); - expect(result.json()).toEqual({ - message: [ - 'The format of the parent category ID is incorrect.', - 'The parent category does not exist', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with malformed UUID parent id', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - parent: 'not-a-valid-uuid-123', - }, - }); - expect(result.json()).toEqual({ - message: [ - 'The format of the parent category ID is incorrect.', - 'The parent category does not exist', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with customOrder as string', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - customOrder: '10', // 字符串形式的数字 - }, - }); - expect(result.statusCode).toEqual(201); - const category: CategoryEntity = result.json(); - expect(category.customOrder).toBe(10); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - it('create category with customOrder as float', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - customOrder: 5.5, - }, - }); - expect(result.json()).toEqual({ - message: ['customOrder must be an integer number'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with customOrder as negative number', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - customOrder: -1, - }, - }); - expect(result.json()).toEqual({ - message: ['The sorted value must be greater than 0.'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create category with customOrder as zero', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - customOrder: 0, - }, - }); - expect(result.statusCode).toEqual(201); - const category: CategoryEntity = result.json(); - expect(category.customOrder).toBe(0); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - it('create category with customOrder as large number', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'New Category', - customOrder: 999999, - }, - }); - expect(result.statusCode).toEqual(201); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - it('create category with all valid data', async () => { - const parent = categories.find((c) => !c.parent); - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'Valid New Category', - parent: parent.id, - customOrder: 5, - }, - }); - expect(result.statusCode).toEqual(201); - const category: CategoryEntity = result.json(); - expect(category.name).toBe('Valid New Category'); - expect(category.parent.id).toBe(parent.id); - expect(category.customOrder).toBe(5); - await app.inject({ - method: 'DELETE', - url: `${URL_PREFIX}/category/${result.json().id}`, - }); - }); - - // 树形结构特殊场景测试 - it('create category with parent as self (should fail)', async () => { - const category = categories[0]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { - name: 'Invalid Category', - parent: category.id, - id: category.id, // 尝试设置自己的ID为parent - }, - }); - // 这里假设后端有循环引用检查 - expect(result.statusCode).toEqual(400); - }); - - // 更新分类验证 - it('update category without id', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { name: 'Updated Category' }, - }); - expect(result.json()).toEqual({ - message: [ - 'The ID must be specified', - 'The ID format is incorrect', - 'The Category names are duplicated', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update category with invalid id format', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { - id: 'invalid-uuid', - name: 'Updated Category', - }, - }); - expect(result.json()).toEqual({ - message: [ - 'The ID format is incorrect', - 'category id not exist when update', - 'The Category names are duplicated', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update category with non-existent id', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { - id: '74e655b3-b69a-42ae-a101-41c224386e74', - name: 'Updated Category', - }, - }); - expect(result.statusCode).toEqual(400); - }); - - it('update category with long name', async () => { - const category = categories[0]; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { - id: category.id, - name: 'A'.repeat(30), - }, - }); - expect(result.json()).toEqual({ - message: ['The length of the category name shall not exceed 25'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update category with duplicate name in same parent', async () => { - const parentCategory = categories.find((c) => c.children?.length > 1); - const [child1, child2] = parentCategory.children; - - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { - id: child1.id, - name: child2.name, - }, - }); - expect(result.json()).toEqual({ - message: ['The Category names are duplicated'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update category with invalid parent id format', async () => { - const category = categories[0]; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { - id: category.id, - parent: 'invalid-uuid', - }, - }); - expect(result.json()).toEqual({ - message: [ - 'The format of the parent category ID is incorrect.', - 'The parent category does not exist', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update category with non-existent parent id', async () => { - const category = categories[0]; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { - id: category.id, - parent: '74e655b3-b69a-42ae-a101-41c224386e74', - }, - }); - expect(result.json()).toEqual({ - message: ['The parent category does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update category with negative custom order', async () => { - const category = categories[0]; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/category`, - body: { - id: category.id, - customOrder: -1, - }, - }); - expect(result.json()).toEqual({ - message: ['The sorted value must be greater than 0.'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - // 查询分类验证 - it('query categories with invalid page', async () => { - const result = await app.inject({ - method: 'GET', - url: `${URL_PREFIX}/category?page=0`, - }); - expect(result.json()).toEqual({ - message: ['The current page must be greater than 1.'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('query categories with invalid limit', async () => { - const result = await app.inject({ - method: 'GET', - url: `${URL_PREFIX}/category?limit=0`, - }); - expect(result.json()).toEqual({ - message: ['The number of data displayed per page must be greater than 1.'], - error: 'Bad Request', - statusCode: 400, - }); - }); - }); - - describe('tag test', () => { - it('tag init', () => { - expect(tagRepository).toBeDefined(); - }); - it('tag test data check', () => { - expect(tags.length).toEqual(tagData.length); - }); - it('create tag without name', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/tag`, - body: {}, - }); - expect(result.json()).toEqual({ - message: [ - 'The classification name cannot be empty', - 'The maximum length of the label name is 255', - 'The label names are repeated', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create tag with long name', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/tag`, - body: { name: 'A'.repeat(256) }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of the label name is 255'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create tag with duplicate name', async () => { - const existingTag = tags[0]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/tag`, - body: { name: existingTag.name }, - }); - expect(result.json()).toEqual({ - message: ['The label names are repeated'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create tag with long description', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/tag`, - body: { - name: 'NewTag', - desc: 'A'.repeat(501), - }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of the label description is 500'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - // 更新标签验证 - it('update tag without id', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/tag`, - body: { name: 'Updated Tag' }, - }); - expect(result.json()).toEqual({ - message: [ - 'The ID must be specified', - 'The ID format is incorrect', - 'The label names are repeated', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update tag with invalid id format', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/tag`, - body: { - id: 'invalid-uuid', - name: 'Updated Tag', - }, - }); - expect(result.json()).toEqual({ - message: ['The ID format is incorrect', 'tag id not exist when update'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update tag with non-existent id', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/tag`, - body: { - id: '74e655b3-b69a-42ae-a101-41c224386e74', - name: 'Updated Tag', - }, - }); - expect(result.json()).toEqual({ - message: ['tag id not exist when update'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update tag with long name', async () => { - const tag = tags[0]; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/tag`, - body: { - id: tag.id, - name: 'A'.repeat(256), - }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of the label name is 255'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update tag with duplicate name', async () => { - const [tag1, tag2] = tags; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/tag`, - body: { - id: tag1.id, - name: tag2.name, - }, - }); - expect(result.json()).toEqual({ - message: ['The label names are repeated'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update tag with long description', async () => { - const tag = tags[0]; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/tag`, - body: { - id: tag.id, - desc: 'A'.repeat(501), - }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of the label description is 500'], - error: 'Bad Request', - statusCode: 400, - }); - }); - }); - - describe('posts test', () => { - it('posts init', () => { - expect(postRepository).toBeDefined(); - }); - it('posts test data check', () => { - expect(posts.length).toEqual(postData.length); - }); - - // 创建文章验证 - it('create post without title', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { body: 'Post content' }, - }); - expect(result.json()).toEqual({ - message: [ - 'The article title must be filled in.', - 'The maximum length of the article title is 255', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post without body', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { title: 'New Post' }, - }); - expect(result.json()).toEqual({ - message: ['The content of the article must be filled in.'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with long title', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'A'.repeat(256), - body: 'Post content', - }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of the article title is 255'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with long summary', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'New Post', - body: 'Content', - summary: 'A'.repeat(501), - }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of the article description is 500'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with invalid category', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'New Post', - body: 'Content', - category: 'invalid-uuid', - }, - }); - expect(result.json()).toEqual({ - message: ['The ID format is incorrect', 'The category does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with non-existent category', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'New Post', - body: 'Content', - category: '74e655b3-b69a-42ae-a101-41c224386e74', - }, - }); - expect(result.json()).toEqual({ - message: ['The category does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with invalid tag format', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'New Post', - body: 'Content', - tags: ['invalid-uuid'], - }, - }); - expect(result.json()).toEqual({ - message: ['The ID format is incorrect', 'The tag does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with non-existent tag', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'New Post', - body: 'Content', - tags: ['74e655b3-b69a-42ae-a101-41c224386e74'], - }, - }); - expect(result.json()).toEqual({ - message: ['The tag does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with long keyword', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'New Post', - body: 'Content', - keywords: ['keyword1', 'A'.repeat(21)], - }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of each keyword is 20'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create post with negative custom order', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: { - title: 'New Post', - body: 'Content', - customOrder: -1, - }, - }); - expect(result.json()).toEqual({ - message: ['The sorted value must be greater than 0.'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - // 更新文章验证 - it('update post without id', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/posts`, - body: { title: 'Updated Post' }, - }); - expect(result.json()).toEqual({ - message: [ - 'The article ID must be specified', - 'The format of the article ID is incorrect.', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update post with invalid id format', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/posts`, - body: { - id: 'invalid-uuid', - title: 'Updated Post', - }, - }); - expect(result.json()).toEqual({ - message: [ - 'The format of the article ID is incorrect.', - 'post id not exist when update', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update post with non-existent id', async () => { - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/posts`, - body: { - id: '74e655b3-b69a-42ae-a101-41c224386e74', - title: 'Updated Post non-existent id', - }, - }); - expect(result.json()).toEqual({ - message: ['post id not exist when update'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('update post with long title', async () => { - const post = posts[0]; - const result = await app.inject({ - method: 'PATCH', - url: `${URL_PREFIX}/posts`, - body: { - id: post.id, - title: 'A'.repeat(256), - }, - }); - expect(result.json()).toEqual({ - message: ['The maximum length of the article title is 255'], - error: 'Bad Request', - statusCode: 400, - }); - }); - }); - - describe('comment test', () => { - it('comment init', () => { - expect(commentRepository).toBeDefined(); - }); - it('comment test data check', () => { - expect(comments.length).toEqual(commentData.length); - }); - - // 创建评论验证 - it('create comment without body', async () => { - const post = posts[0]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: { post: post.id }, - }); - expect(result.json()).toEqual({ - message: [ - 'Comment content cannot be empty', - 'The length of the comment content cannot exceed 1000', - ], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create comment without post', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: { body: 'Test comment' }, - }); - expect(result.json()).toEqual({ - message: ['The post ID must be specified', 'The ID format is incorrect'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create comment with long body', async () => { - const post = posts[0]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: { - body: 'A'.repeat(1001), - post: post.id, - }, - }); - expect(result.json()).toEqual({ - message: ['The length of the comment content cannot exceed 1000'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create comment with invalid post format', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: { - body: 'Test comment', - post: 'invalid-uuid', - }, - }); - expect(result.json()).toEqual({ - message: ['The ID format is incorrect', 'The post does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create comment with non-existent post', async () => { - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: { - body: 'Test comment', - post: '74e655b3-b69a-42ae-a101-41c224386e74', - }, - }); - expect(result.json()).toEqual({ - message: ['The post does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create comment with invalid parent format', async () => { - const post = posts[0]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: { - body: 'Test comment', - post: post.id, - parent: 'invalid-uuid', - }, - }); - expect(result.json()).toEqual({ - message: ['The ID format is incorrect', 'The parent comment does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - - it('create comment with non-existent parent', async () => { - const post = posts[0]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: { - body: 'Test comment', - post: post.id, - parent: '74e655b3-b69a-42ae-a101-41c224386e74', - }, - }); - expect(result.json()).toEqual({ - message: ['The parent comment does not exist'], - error: 'Bad Request', - statusCode: 400, - }); - }); - }); - - afterAll(async () => { - await datasource.destroy(); // 关闭数据库连接 - await app.close(); - }); -}); - -async function addCategory( - app: NestFastifyApplication, - data: RecordAny[], - parentId?: string, -): Promise { - const results: CategoryEntity[] = []; - if (app && data && data.length > 0) { - for (let index = 0; index < data.length; index++) { - const item = data[index]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/category`, - body: { ...pick(item, ['name', 'customOrder']), parent: parentId }, - }); - const addedItem: CategoryEntity = result.json(); - results.push(addedItem); - results.push(...(await addCategory(app, item.children, addedItem.id))); - } - } - return results; -} - -async function addTag(app: NestFastifyApplication, data: RecordAny[]): Promise { - const results: TagEntity[] = []; - if (app && data && data.length > 0) { - for (let index = 0; index < data.length; index++) { - const item = data[index]; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/tag`, - body: item, - }); - const addedItem: TagEntity = result.json(); - results.push(addedItem); - } - } - return results; -} - -async function addPost( - app: NestFastifyApplication, - data: RecordAny[], - tags: string[] = [], - categories: string[] = [], -) { - const results: PostEntity[] = []; - if (app && data && data.length > 0) { - for (let index = 0; index < data.length; index++) { - const item = data[index]; - item.category = categories[generateRandomNumber(1, categories.length - 1)[0]]; - item.tags = generateUniqueRandomNumbers(0, tags.length - 1, 3).map((idx) => tags[idx]); - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/posts`, - body: item, - }); - const addedItem: PostEntity = result.json(); - results.push(addedItem); - } - } - return results; -} - -async function addComment( - app: NestFastifyApplication, - data: RecordAny[], - posts: string[], -): Promise { - const results: CommentEntity[] = []; - if (app && data && data.length > 0) { - for (let index = 0; index < data.length; index++) { - const item = data[index]; - item.post = posts[generateRandomNumber(0, posts.length - 1)[0]]; - - const commentsFilter = results - .filter((comment) => comment.post === item.post) - .map((comment) => comment.id); - item.parent = - commentsFilter.length > 0 - ? commentsFilter[generateRandomNumber(0, commentsFilter.length - 1)[0]] - : undefined; - const result = await app.inject({ - method: 'POST', - url: `${URL_PREFIX}/comment`, - body: item, - }); - const addedItem = result.json(); - results.push(addedItem); - } - } - return results; -} diff --git a/test/controllers/README.md b/test/controllers/README.md new file mode 100644 index 0000000..fa3d561 --- /dev/null +++ b/test/controllers/README.md @@ -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); + + 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. 添加适当的注释和文档 diff --git a/test/controllers/account.controller.test.ts b/test/controllers/account.controller.test.ts new file mode 100644 index 0000000..72c7720 --- /dev/null +++ b/test/controllers/account.controller.test.ts @@ -0,0 +1,580 @@ +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); + datasource = app.get(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(); + console.log(token); + 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}`, + }, + }); + console.log(result.json()); + 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('两次输入密码不同'); + }); + }); +}); diff --git a/test/controllers/category.controller.test.ts b/test/controllers/category.controller.test.ts new file mode 100644 index 0000000..fd3e787 --- /dev/null +++ b/test/controllers/category.controller.test.ts @@ -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); + categoryService = app.get(CategoryService); + datasource = app.get(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(); + }); + }); +}); diff --git a/test/controllers/comment.controller.test.ts b/test/controllers/comment.controller.test.ts new file mode 100644 index 0000000..8bdb359 --- /dev/null +++ b/test/controllers/comment.controller.test.ts @@ -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); + commentRepository = app.get(CommentRepository); + postRepository = app.get(PostRepository); + categoryRepository = app.get(CategoryRepository); + userRepository = app.get(UserRepository); + permissionRepository = app.get(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', + ); + }); + }); +}); diff --git a/test/controllers/manager/category.controller.test.ts b/test/controllers/manager/category.controller.test.ts new file mode 100644 index 0000000..4668280 --- /dev/null +++ b/test/controllers/manager/category.controller.test.ts @@ -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); + userRepository = app.get(UserRepository); + permissionRepository = app.get(PermissionRepository); + datasource = app.get(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); + }); + }); +}); diff --git a/test/controllers/manager/comment.controller.test.ts b/test/controllers/manager/comment.controller.test.ts new file mode 100644 index 0000000..5193814 --- /dev/null +++ b/test/controllers/manager/comment.controller.test.ts @@ -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); + postRepository = app.get(PostRepository); + categoryRepository = app.get(CategoryRepository); + userRepository = app.get(UserRepository); + permissionRepository = app.get(PermissionRepository); + datasource = app.get(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(); + }); + }); +}); diff --git a/test/controllers/manager/permission.controller.test.ts b/test/controllers/manager/permission.controller.test.ts new file mode 100644 index 0000000..be3367a --- /dev/null +++ b/test/controllers/manager/permission.controller.test.ts @@ -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); + userRepository = app.get(UserRepository); + datasource = app.get(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); + }); + }); +}); diff --git a/test/controllers/manager/post.controller.test.ts b/test/controllers/manager/post.controller.test.ts new file mode 100644 index 0000000..536478f --- /dev/null +++ b/test/controllers/manager/post.controller.test.ts @@ -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); + categoryRepository = app.get(CategoryRepository); + tagRepository = app.get(TagRepository); + userRepository = app.get(UserRepository); + permissionRepository = app.get(PermissionRepository); + postService = app.get(PostService); + datasource = app.get(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); + }); + }); +}); diff --git a/test/controllers/manager/role.controller.test.ts b/test/controllers/manager/role.controller.test.ts new file mode 100644 index 0000000..380f8bd --- /dev/null +++ b/test/controllers/manager/role.controller.test.ts @@ -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); + permissionRepository = app.get(PermissionRepository); + userRepository = app.get(UserRepository); + datasource = app.get(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); + }); + }); +}); diff --git a/test/controllers/manager/tag.controller.test.ts b/test/controllers/manager/tag.controller.test.ts new file mode 100644 index 0000000..610ed09 --- /dev/null +++ b/test/controllers/manager/tag.controller.test.ts @@ -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); + tagRepository = app.get(TagRepository); + userRepository = app.get(UserRepository); + datasource = app.get(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); + }); + }); +}); diff --git a/test/controllers/manager/user.controller.test.ts b/test/controllers/manager/user.controller.test.ts new file mode 100644 index 0000000..cab52d5 --- /dev/null +++ b/test/controllers/manager/user.controller.test.ts @@ -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); + permissionRepository = app.get(PermissionRepository); + datasource = app.get(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); + }); + }); +}); diff --git a/test/controllers/post.controller.test.ts b/test/controllers/post.controller.test.ts new file mode 100644 index 0000000..c7e9534 --- /dev/null +++ b/test/controllers/post.controller.test.ts @@ -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); + postRepository = app.get(PostRepository); + categoryRepository = app.get(CategoryRepository); + tagRepository = app.get(TagRepository); + userRepository = app.get(UserRepository); + postService = app.get(PostService); + datasource = app.get(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); + }); + }); +}); diff --git a/test/controllers/role.controller.test.ts b/test/controllers/role.controller.test.ts new file mode 100644 index 0000000..cc55a79 --- /dev/null +++ b/test/controllers/role.controller.test.ts @@ -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); + datasource = app.get(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(); + }); + }); +}); diff --git a/test/controllers/tag.controller.test.ts b/test/controllers/tag.controller.test.ts new file mode 100644 index 0000000..644879f --- /dev/null +++ b/test/controllers/tag.controller.test.ts @@ -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); + datasource = app.get(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)'); + }); + }); +}); diff --git a/test/controllers/user.controller.test.ts b/test/controllers/user.controller.test.ts new file mode 100644 index 0000000..7bcf5d9 --- /dev/null +++ b/test/controllers/user.controller.test.ts @@ -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); + datasource = app.get(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)'); + }); + }); +});