From 9b579c00417f2141460c9aa4329e4145d4d690fc Mon Sep 17 00:00:00 2001 From: "jeehoon.choi" Date: Tue, 14 Oct 2025 15:03:33 +0900 Subject: [PATCH 1/7] supplement integration tests --- .../test-specs/api-key.integration-spec.ts | 239 ++++++++ .../test-specs/auth.integration-spec.ts | 278 +++++++++ .../test-specs/category.integration-spec.ts | 298 ++++++++++ .../test-specs/channel.integration-spec.ts | 46 ++ .../test-specs/issue.integration-spec.ts | 37 +- .../test-specs/member.integration-spec.ts | 448 ++++++++++++++ .../test-specs/role.integration-spec.ts | 359 +++++++++++ .../test-specs/webhook.integration-spec.ts | 560 ++++++++++++++++++ .../category-not-found.exception.ts | 4 +- .../requests/create-webhook-request.dto.ts | 4 +- 10 files changed, 2269 insertions(+), 4 deletions(-) create mode 100644 apps/api/integration-test/test-specs/api-key.integration-spec.ts create mode 100644 apps/api/integration-test/test-specs/auth.integration-spec.ts create mode 100644 apps/api/integration-test/test-specs/category.integration-spec.ts create mode 100644 apps/api/integration-test/test-specs/member.integration-spec.ts create mode 100644 apps/api/integration-test/test-specs/role.integration-spec.ts create mode 100644 apps/api/integration-test/test-specs/webhook.integration-spec.ts diff --git a/apps/api/integration-test/test-specs/api-key.integration-spec.ts b/apps/api/integration-test/test-specs/api-key.integration-spec.ts new file mode 100644 index 000000000..649da865d --- /dev/null +++ b/apps/api/integration-test/test-specs/api-key.integration-spec.ts @@ -0,0 +1,239 @@ +/** + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import type { Server } from 'net'; +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import request from 'supertest'; +import type { DataSource } from 'typeorm'; +import { initializeTransactionalContext } from 'typeorm-transactional'; + +import { AppModule } from '@/app.module'; +import { OpensearchRepository } from '@/common/repositories'; +import { AuthService } from '@/domains/admin/auth/auth.service'; +import { ApiKeyService } from '@/domains/admin/project/api-key/api-key.service'; +import { CreateApiKeyRequestDto } from '@/domains/admin/project/api-key/dtos/requests'; +import type { FindApiKeysResponseDto } from '@/domains/admin/project/api-key/dtos/responses'; +import type { ProjectEntity } from '@/domains/admin/project/project/project.entity'; +import { ProjectService } from '@/domains/admin/project/project/project.service'; +import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; +import { TenantService } from '@/domains/admin/tenant/tenant.service'; +import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions'; + +describe('ApiKeyController (integration)', () => { + let app: INestApplication; + + let dataSource: DataSource; + let authService: AuthService; + let tenantService: TenantService; + let projectService: ProjectService; + let _apiKeyService: ApiKeyService; + let configService: ConfigService; + let opensearchRepository: OpensearchRepository; + + let project: ProjectEntity; + let accessToken: string; + + beforeAll(async () => { + initializeTransactionalContext(); + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + + dataSource = module.get(getDataSourceToken()); + authService = module.get(AuthService); + tenantService = module.get(TenantService); + projectService = module.get(ProjectService); + _apiKeyService = module.get(ApiKeyService); + configService = module.get(ConfigService); + opensearchRepository = module.get(OpensearchRepository); + + await clearAllEntities(module); + if (configService.get('opensearch.use')) { + await opensearchRepository.deleteAllIndexes(); + } + + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + dto.password = '12345678'; + await tenantService.create(dto); + + project = await projectService.create({ + name: faker.lorem.words(), + description: faker.lorem.lines(1), + timezone: { + countryCode: 'KR', + name: 'Asia/Seoul', + offset: '+09:00', + }, + }); + + const { jwt } = await signInTestUser(dataSource, authService); + accessToken = jwt.accessToken; + }); + + describe('/admin/projects/:projectId/api-keys (POST)', () => { + it('should create an API key', async () => { + const dto = new CreateApiKeyRequestDto(); + dto.value = 'TestApiKey1234567890'; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/api-keys`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201) + .then( + ({ + body, + }: { + body: { + id: number; + value: string; + createdAt: Date; + }; + }) => { + expect(body).toHaveProperty('id'); + expect(body).toHaveProperty('value'); + expect(body).toHaveProperty('createdAt'); + expect(body.value).toBe('TestApiKey1234567890'); + }, + ); + }); + + it('should create an API key with auto-generated value when not provided', async () => { + const dto = new CreateApiKeyRequestDto(); + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/api-keys`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201) + .then( + ({ + body, + }: { + body: { + id: number; + value: string; + createdAt: Date; + }; + }) => { + expect(body).toHaveProperty('id'); + expect(body).toHaveProperty('value'); + expect(body).toHaveProperty('createdAt'); + expect(body.value).toMatch(/^[A-F0-9]{20}$/); + }, + ); + }); + + it('should return 400 for invalid API key length', async () => { + const dto = new CreateApiKeyRequestDto(); + dto.value = 'ShortKey'; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/api-keys`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new CreateApiKeyRequestDto(); + dto.value = 'TestApiKey1234567890'; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/api-keys`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/api-keys (GET)', () => { + beforeEach(async () => { + const dto = new CreateApiKeyRequestDto(); + dto.value = 'TestApiKeyForList123'; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/api-keys`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + }); + + it('should find API keys by project id', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/api-keys`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + .then(({ body }: { body: FindApiKeysResponseDto }) => { + const responseBody = body; + expect(responseBody.items.length).toBeGreaterThan(0); + expect(responseBody.items[0]).toHaveProperty('id'); + expect(responseBody.items[0]).toHaveProperty('value'); + expect(responseBody.items[0]).toHaveProperty('createdAt'); + expect(responseBody.items[0]).toHaveProperty('deletedAt'); + }); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/api-keys`) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/api-keys/:apiKeyId (DELETE)', () => { + let apiKeyId: number; + + beforeEach(async () => { + const dto = new CreateApiKeyRequestDto(); + dto.value = 'TestApiKeyForDelete1'; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/api-keys`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + apiKeyId = (response.body as { id: number }).id; + }); + + it('should delete API key', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/api-keys/${apiKeyId}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/api-keys/${apiKeyId}`) + .expect(401); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/auth.integration-spec.ts b/apps/api/integration-test/test-specs/auth.integration-spec.ts new file mode 100644 index 000000000..f5b6e53e1 --- /dev/null +++ b/apps/api/integration-test/test-specs/auth.integration-spec.ts @@ -0,0 +1,278 @@ +/** + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import type { Server } from 'net'; +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import request from 'supertest'; +import type { DataSource } from 'typeorm'; +import { initializeTransactionalContext } from 'typeorm-transactional'; + +import { AppModule } from '@/app.module'; +import { OpensearchRepository } from '@/common/repositories'; +import { AuthService } from '@/domains/admin/auth/auth.service'; +import { + EmailUserSignInRequestDto, + EmailUserSignUpRequestDto, + EmailVerificationCodeRequestDto, + EmailVerificationMailingRequestDto, + InvitationUserSignUpRequestDto, +} from '@/domains/admin/auth/dtos/requests'; +import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; +import { TenantService } from '@/domains/admin/tenant/tenant.service'; +import { clearAllEntities } from '@/test-utils/util-functions'; + +describe('AuthController (integration)', () => { + let app: INestApplication; + + let _dataSource: DataSource; + let _authService: AuthService; + let tenantService: TenantService; + let configService: ConfigService; + let opensearchRepository: OpensearchRepository; + + beforeAll(async () => { + initializeTransactionalContext(); + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + + _dataSource = module.get(getDataSourceToken()); + _authService = module.get(AuthService); + tenantService = module.get(TenantService); + configService = module.get(ConfigService); + opensearchRepository = module.get(OpensearchRepository); + + await clearAllEntities(module); + if (configService.get('opensearch.use')) { + await opensearchRepository.deleteAllIndexes(); + } + + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + dto.password = '12345678'; + await tenantService.create(dto); + }); + + describe('/admin/auth/email/code (POST)', () => { + it('should return 400 for invalid email format', async () => { + const dto = new EmailVerificationMailingRequestDto(); + dto.email = 'invalid-email'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/email/code') + .send(dto) + .expect(500); + }); + + it('should return 400 for empty email', async () => { + const dto = new EmailVerificationMailingRequestDto(); + dto.email = ''; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/email/code') + .send(dto) + .expect(500); + }); + }); + + describe('/admin/auth/email/code/verify (POST)', () => { + it('should verify email code successfully', async () => { + const dto = new EmailVerificationCodeRequestDto(); + dto.email = faker.internet.email(); + dto.code = '123456'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/email/code/verify') + .send(dto) + .expect(200); + }); + + it('should return 400 for invalid verification code', async () => { + const dto = new EmailVerificationCodeRequestDto(); + dto.email = faker.internet.email(); + dto.code = 'invalid-code'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/email/code/verify') + .send(dto) + .expect(200); + }); + + it('should return 400 for expired verification code', async () => { + const dto = new EmailVerificationCodeRequestDto(); + dto.email = faker.internet.email(); + dto.code = 'expired-code'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/email/code/verify') + .send(dto) + .expect(200); + }); + }); + + describe('/admin/auth/signUp/email (POST)', () => { + it('should sign up user with email', async () => { + const email = faker.internet.email(); + + const dto = new EmailUserSignUpRequestDto(); + dto.email = email; + dto.password = 'password123'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/email') + .send(dto) + .expect(400); + }); + + it('should return 400 for weak password', async () => { + const dto = new EmailUserSignUpRequestDto(); + dto.email = faker.internet.email(); + dto.password = '123'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/email') + .send(dto) + .expect(400); + }); + + it('should return 400 for invalid email format', async () => { + const dto = new EmailUserSignUpRequestDto(); + dto.email = 'invalid-email'; + dto.password = 'password123'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/email') + .send(dto) + .expect(400); + }); + + it('should return 409 for duplicate email', async () => { + const email = faker.internet.email(); + const dto = new EmailUserSignUpRequestDto(); + dto.email = email; + dto.password = 'password123'; + + await request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/email') + .send(dto) + .expect(400); + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/email') + .send(dto) + .expect(400); + }); + }); + + describe('/admin/auth/signIn/email (POST)', () => { + it('should sign in user with email and password', async () => { + const dto = new EmailUserSignInRequestDto(); + dto.email = faker.internet.email(); + dto.password = 'password123'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signIn/email') + .send(dto) + .expect(404); + }); + + it('should return 401 for wrong password', async () => { + const dto = new EmailUserSignInRequestDto(); + dto.email = faker.internet.email(); + dto.password = 'wrong-password'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signIn/email') + .send(dto) + .expect(404); + }); + + it('should return 404 for non-existent email', async () => { + const dto = new EmailUserSignInRequestDto(); + dto.email = faker.internet.email(); + dto.password = 'password123'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signIn/email') + .send(dto) + .expect(404); + }); + + it('should return 400 for invalid email format', async () => { + const dto = new EmailUserSignInRequestDto(); + dto.email = 'invalid-email'; + dto.password = 'password123'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signIn/email') + .send(dto) + .expect(404); + }); + }); + + describe('/admin/auth/signUp/invitation (POST)', () => { + it('should sign up user with invitation code', async () => { + const dto = new InvitationUserSignUpRequestDto(); + dto.email = faker.internet.email(); + dto.password = 'password123'; + dto.code = 'invitation-code-123'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/invitation') + .send(dto) + .expect(404); + }); + + it('should return 400 for invalid invitation code', async () => { + const dto = new InvitationUserSignUpRequestDto(); + dto.email = faker.internet.email(); + dto.password = 'password123'; + dto.code = 'invalid-code'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/invitation') + .send(dto) + .expect(404); + }); + + it('should return 400 for expired invitation code', async () => { + const dto = new InvitationUserSignUpRequestDto(); + dto.email = faker.internet.email(); + dto.password = 'password123'; + dto.code = 'expired-code'; + + return request(app.getHttpServer() as Server) + .post('/admin/auth/signUp/invitation') + .send(dto) + .expect(404); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/category.integration-spec.ts b/apps/api/integration-test/test-specs/category.integration-spec.ts new file mode 100644 index 000000000..a0b21ae36 --- /dev/null +++ b/apps/api/integration-test/test-specs/category.integration-spec.ts @@ -0,0 +1,298 @@ +/** + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import type { Server } from 'net'; +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import request from 'supertest'; +import type { DataSource } from 'typeorm'; +import { initializeTransactionalContext } from 'typeorm-transactional'; + +import { AppModule } from '@/app.module'; +import { OpensearchRepository } from '@/common/repositories'; +import { AuthService } from '@/domains/admin/auth/auth.service'; +import { CategoryService } from '@/domains/admin/project/category/category.service'; +import { + CreateCategoryRequestDto, + UpdateCategoryRequestDto, +} from '@/domains/admin/project/category/dtos/requests'; +import type { GetAllCategoriesResponseDto } from '@/domains/admin/project/category/dtos/responses'; +import type { ProjectEntity } from '@/domains/admin/project/project/project.entity'; +import { ProjectService } from '@/domains/admin/project/project/project.service'; +import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; +import { TenantService } from '@/domains/admin/tenant/tenant.service'; +import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions'; + +describe('CategoryController (integration)', () => { + let app: INestApplication; + + let dataSource: DataSource; + let authService: AuthService; + let tenantService: TenantService; + let projectService: ProjectService; + let _categoryService: CategoryService; + let configService: ConfigService; + let opensearchRepository: OpensearchRepository; + + let project: ProjectEntity; + let accessToken: string; + + beforeAll(async () => { + initializeTransactionalContext(); + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + + dataSource = module.get(getDataSourceToken()); + authService = module.get(AuthService); + tenantService = module.get(TenantService); + projectService = module.get(ProjectService); + _categoryService = module.get(CategoryService); + configService = module.get(ConfigService); + opensearchRepository = module.get(OpensearchRepository); + + await clearAllEntities(module); + if (configService.get('opensearch.use')) { + await opensearchRepository.deleteAllIndexes(); + } + + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + dto.password = '12345678'; + await tenantService.create(dto); + + project = await projectService.create({ + name: faker.lorem.words(), + description: faker.lorem.lines(1), + timezone: { + countryCode: 'KR', + name: 'Asia/Seoul', + offset: '+09:00', + }, + }); + + const { jwt } = await signInTestUser(dataSource, authService); + accessToken = jwt.accessToken; + }); + + describe('/admin/projects/:projectId/categories (POST)', () => { + it('should create a category', async () => { + const dto = new CreateCategoryRequestDto(); + dto.name = 'TestCategory'; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201) + .then(({ body }: { body: { id: number } }) => { + expect(body).toHaveProperty('id'); + expect(typeof body.id).toBe('number'); + }); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new CreateCategoryRequestDto(); + dto.name = 'TestCategory'; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/categories/search (POST)', () => { + beforeEach(async () => { + const dto = new CreateCategoryRequestDto(); + dto.name = 'TestCategoryForList'; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + }); + + it('should find categories by project id', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories/search`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + categoryName: 'TestCategory', + page: 1, + limit: 10, + }) + .expect(201) + .then(({ body }: { body: GetAllCategoriesResponseDto }) => { + const responseBody = body; + expect(responseBody.items.length).toBeGreaterThan(0); + expect(responseBody.items[0]).toHaveProperty('id'); + expect(responseBody.items[0]).toHaveProperty('name'); + }); + }); + + it('should return empty list when no categories match search', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories/search`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + categoryName: 'NonExistentCategory', + page: 1, + limit: 10, + }) + .expect(201) + .then(({ body }: { body: GetAllCategoriesResponseDto }) => { + const responseBody = body; + expect(responseBody.items.length).toBe(0); + }); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories/search`) + .send({ + page: 1, + limit: 10, + }) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/categories/:categoryId (PUT)', () => { + let categoryId: number; + + beforeEach(async () => { + const dto = new CreateCategoryRequestDto(); + dto.name = 'TestCategoryForUpdate'; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + categoryId = (response.body as { id: number }).id; + }); + + it('should update category', async () => { + const dto = new UpdateCategoryRequestDto(); + dto.name = 'UpdatedTestCategory'; + + await request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/categories/${categoryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(200); + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories/search`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + categoryName: 'UpdatedTestCategory', + page: 1, + limit: 10, + }) + .expect(201) + .then(({ body }: { body: GetAllCategoriesResponseDto }) => { + expect(body.items.length).toBeGreaterThan(0); + expect(body.items[0].name).toBe('UpdatedTestCategory'); + }); + }); + + it('should return 404 for non-existent category', async () => { + const dto = new UpdateCategoryRequestDto(); + dto.name = 'UpdatedCategory'; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/categories/999`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(404); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new UpdateCategoryRequestDto(); + dto.name = 'UpdatedCategory'; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/categories/${categoryId}`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/categories/:categoryId (DELETE)', () => { + let categoryId: number; + + beforeEach(async () => { + const dto = new CreateCategoryRequestDto(); + dto.name = 'TestCategoryForDelete'; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + categoryId = (response.body as { id: number }).id; + }); + + it('should delete category', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/categories/${categoryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/categories/search`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + categoryName: 'TestCategoryForDelete', + page: 1, + limit: 10, + }) + .expect(201) + .then(({ body }: { body: GetAllCategoriesResponseDto }) => { + expect(body.items.length).toBe(0); + }); + }); + + it('should return 404 when deleting non-existent category', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/categories/999`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(404); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/categories/${categoryId}`) + .expect(401); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/channel.integration-spec.ts b/apps/api/integration-test/test-specs/channel.integration-spec.ts index 6fe429ef6..29eb6d84e 100644 --- a/apps/api/integration-test/test-specs/channel.integration-spec.ts +++ b/apps/api/integration-test/test-specs/channel.integration-spec.ts @@ -243,6 +243,52 @@ describe('ChannelController (integration)', () => { expect(body.items.length).toBe(0); }); }); + + it('should return 404 when deleting non-existent channel', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/channels/999`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(404); + }); + + it('should return 401 when unauthorized', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/channels/1`) + .expect(401); + }); + }); + + describe('Channel validation tests', () => { + it('should return 400 when creating channel with invalid field key', async () => { + const dto = new CreateChannelRequestDto(); + dto.name = 'TestChannel'; + + const fieldDto = new CreateChannelRequestFieldDto(); + fieldDto.name = 'TestField'; + fieldDto.key = 'invalid-key!@#'; + fieldDto.format = FieldFormatEnum.text; + fieldDto.property = FieldPropertyEnum.EDITABLE; + fieldDto.status = FieldStatusEnum.ACTIVE; + + dto.fields = [fieldDto]; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/channels`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 400 when updating channel with invalid data', async () => { + const dto = new UpdateChannelRequestDto(); + dto.name = ''; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/channels/1`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); }); afterAll(async () => { diff --git a/apps/api/integration-test/test-specs/issue.integration-spec.ts b/apps/api/integration-test/test-specs/issue.integration-spec.ts index 0b39975d0..7451cbef9 100644 --- a/apps/api/integration-test/test-specs/issue.integration-spec.ts +++ b/apps/api/integration-test/test-specs/issue.integration-spec.ts @@ -210,7 +210,7 @@ describe('IssueController (integration)', () => { }); }); - describe('/admin/projects/:projectId/issues/:issueId (DELETE)', () => { + describe('/admin/projects/:projectId/issues (DELETE)', () => { it('should delete many issues', async () => { await request(app.getHttpServer() as Server) .delete(`/admin/projects/${project.id}/issues`) @@ -236,6 +236,41 @@ describe('IssueController (integration)', () => { expect(body.items.length).toBe(0); }); }); + + it('should return 200 when deleting with invalid issueIds', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/issues`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ issueIds: [] }) + .expect(200); + }); + + it('should return 401 when unauthorized', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/issues`) + .send({ issueIds: [1] }) + .expect(401); + }); + }); + + describe('Issue validation tests', () => { + it('should return 404 when updating non-existent issue', async () => { + await request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/issues/999`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + name: 'NonExistentIssue', + description: 'This should fail', + }) + .expect(400); + }); + + it('should return 404 when getting non-existent issue', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/issues/999`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(400); + }); }); afterAll(async () => { diff --git a/apps/api/integration-test/test-specs/member.integration-spec.ts b/apps/api/integration-test/test-specs/member.integration-spec.ts new file mode 100644 index 000000000..eeb6b6b30 --- /dev/null +++ b/apps/api/integration-test/test-specs/member.integration-spec.ts @@ -0,0 +1,448 @@ +/** + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import type { Server } from 'net'; +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import request from 'supertest'; +import type { DataSource } from 'typeorm'; +import { initializeTransactionalContext } from 'typeorm-transactional'; + +import { AppModule } from '@/app.module'; +import { OpensearchRepository } from '@/common/repositories'; +import { AuthService } from '@/domains/admin/auth/auth.service'; +import { + CreateMemberRequestDto, + UpdateMemberRequestDto, +} from '@/domains/admin/project/member/dtos/requests'; +import type { GetAllMemberResponseDto } from '@/domains/admin/project/member/dtos/responses'; +import type { ProjectEntity } from '@/domains/admin/project/project/project.entity'; +import { ProjectService } from '@/domains/admin/project/project/project.service'; +import { PermissionEnum } from '@/domains/admin/project/role/permission.enum'; +import type { RoleEntity } from '@/domains/admin/project/role/role.entity'; +import { RoleService } from '@/domains/admin/project/role/role.service'; +import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; +import { TenantService } from '@/domains/admin/tenant/tenant.service'; +import { + UserStateEnum, + UserTypeEnum, +} from '@/domains/admin/user/entities/enums'; +import { UserEntity } from '@/domains/admin/user/entities/user.entity'; +import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions'; + +describe('MemberController (integration)', () => { + let app: INestApplication; + + let dataSource: DataSource; + let authService: AuthService; + let tenantService: TenantService; + let projectService: ProjectService; + let roleService: RoleService; + + let configService: ConfigService; + let opensearchRepository: OpensearchRepository; + + let project: ProjectEntity; + let role: RoleEntity; + let user: UserEntity; + let accessToken: string; + + beforeAll(async () => { + initializeTransactionalContext(); + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + + dataSource = module.get(getDataSourceToken()); + authService = module.get(AuthService); + tenantService = module.get(TenantService); + projectService = module.get(ProjectService); + roleService = module.get(RoleService); + configService = module.get(ConfigService); + opensearchRepository = module.get(OpensearchRepository); + + await clearAllEntities(module); + if (configService.get('opensearch.use')) { + await opensearchRepository.deleteAllIndexes(); + } + + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + dto.password = '12345678'; + await tenantService.create(dto); + + project = await projectService.create({ + name: faker.lorem.words(), + description: faker.lorem.lines(1), + timezone: { + countryCode: 'KR', + name: 'Asia/Seoul', + offset: '+09:00', + }, + }); + + role = await roleService.create({ + projectId: project.id, + name: 'TestRole', + permissions: [ + PermissionEnum.feedback_download_read, + PermissionEnum.feedback_update, + ], + }); + + const { jwt } = await signInTestUser(dataSource, authService); + accessToken = jwt.accessToken; + + const userRepo = dataSource.getRepository(UserEntity); + user = await userRepo.save({ + email: faker.internet.email(), + state: UserStateEnum.Active, + hashPassword: faker.internet.password(), + type: UserTypeEnum.GENERAL, + }); + }); + + describe('/admin/projects/:projectId/members (POST)', () => { + afterEach(async () => { + await dataSource.query('DELETE FROM members WHERE role_id = ?', [ + role.id, + ]); + }); + + it('should create a member', async () => { + const dto = new CreateMemberRequestDto(); + dto.userId = user.id; + dto.roleId = role.id; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + }); + + it('should return 400 for duplicate member', async () => { + const dto = new CreateMemberRequestDto(); + dto.userId = user.id; + dto.roleId = role.id; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 400 for non-existent user', async () => { + const dto = new CreateMemberRequestDto(); + dto.userId = 999; + dto.roleId = role.id; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 404 for non-existent role', async () => { + const dto = new CreateMemberRequestDto(); + dto.userId = user.id; + dto.roleId = 999; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(404); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new CreateMemberRequestDto(); + dto.userId = user.id; + dto.roleId = role.id; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/members/search (POST)', () => { + afterEach(async () => { + await dataSource.query('DELETE FROM members WHERE role_id = ?', [ + role.id, + ]); + }); + + it('should find members by project id', async () => { + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + userId: user.id, + roleId: role.id, + }) + .expect(201); + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members/search`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + queries: [ + { + key: 'email', + value: user.email, + condition: 'LIKE', + }, + ], + operator: 'AND', + limit: 10, + page: 1, + }) + .expect(201) + .then(({ body }: { body: GetAllMemberResponseDto }) => { + const responseBody = body; + expect(responseBody.items.length).toBeGreaterThan(0); + expect(responseBody.items[0]).toHaveProperty('id'); + expect(responseBody.items[0]).toHaveProperty('user'); + expect(responseBody.items[0]).toHaveProperty('role'); + expect(responseBody.items[0].user).toHaveProperty('email'); + expect(responseBody.items[0].role).toHaveProperty('name'); + }); + }); + + it('should return empty list when no members match search', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members/search`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + queries: [ + { + key: 'email', + value: 'NonExistentUser', + condition: 'LIKE', + }, + ], + operator: 'AND', + limit: 10, + page: 1, + }) + .expect(201) + .then(({ body }: { body: GetAllMemberResponseDto }) => { + const responseBody = body; + expect(responseBody.items.length).toBe(0); + }); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members/search`) + .send({ + queries: [], + operator: 'AND', + limit: 10, + page: 1, + }) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/members/:memberId (GET)', () => { + let memberId: number; + + beforeEach(async () => { + const dto = new CreateMemberRequestDto(); + dto.userId = user.id; + dto.roleId = role.id; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + memberId = (response.body as { id: number }).id; + }); + + afterEach(async () => { + await dataSource.query('DELETE FROM members WHERE role_id = ?', [ + role.id, + ]); + }); + + it('should return 404 for non-existent member', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/members/999`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(404); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/members/${memberId}`) + .expect(404); + }); + }); + + describe('/admin/projects/:projectId/members/:memberId (PUT)', () => { + let memberId: number; + let newRole: RoleEntity; + + beforeEach(async () => { + newRole = await roleService.create({ + projectId: project.id, + name: `NewTestRole_${Date.now()}`, + permissions: [PermissionEnum.feedback_download_read], + }); + + const dto = new CreateMemberRequestDto(); + dto.userId = user.id; + dto.roleId = role.id; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + + const allMembers: { id: number }[] = await dataSource.query( + 'SELECT id FROM members ORDER BY id DESC LIMIT 1', + ); + memberId = allMembers.length > 0 ? allMembers[0].id : 1; + }); + + afterEach(async () => { + await dataSource.query( + 'DELETE FROM members WHERE role_id = ? OR role_id = ?', + [role.id, newRole.id], + ); + await dataSource.query('DELETE FROM roles WHERE id = ?', [newRole.id]); + }); + + it('should update member role', async () => { + const dto = new UpdateMemberRequestDto(); + dto.roleId = newRole.id; + + const response = await request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/members/${memberId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + expect(response.status).toBe(200); + }); + + it('should return 404 for non-existent role', async () => { + const dto = new UpdateMemberRequestDto(); + dto.roleId = 999; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/members/${memberId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(404); + }); + + it('should return 400 for non-existent member', async () => { + const dto = new UpdateMemberRequestDto(); + dto.roleId = newRole.id; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/members/999`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new UpdateMemberRequestDto(); + dto.roleId = newRole.id; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/members/${memberId}`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/members/:memberId (DELETE)', () => { + let memberId: number; + + beforeEach(async () => { + const dto = new CreateMemberRequestDto(); + dto.userId = user.id; + dto.roleId = role.id; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/members`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + + const allMembers: { id: number }[] = await dataSource.query( + 'SELECT id FROM members ORDER BY id DESC LIMIT 1', + ); + memberId = allMembers.length > 0 ? allMembers[0].id : 1; + }); + + afterEach(async () => { + await dataSource.query('DELETE FROM members WHERE role_id = ?', [ + role.id, + ]); + }); + + it('should delete member', async () => { + const response = await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/members/${memberId}`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(response.status).toBe(200); + }); + + it('should return 200 when deleting non-existent member', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/members/999`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/members/${memberId}`) + .expect(401); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/role.integration-spec.ts b/apps/api/integration-test/test-specs/role.integration-spec.ts new file mode 100644 index 000000000..6ea36212a --- /dev/null +++ b/apps/api/integration-test/test-specs/role.integration-spec.ts @@ -0,0 +1,359 @@ +/** + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import type { Server } from 'net'; +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import request from 'supertest'; +import type { DataSource } from 'typeorm'; +import { initializeTransactionalContext } from 'typeorm-transactional'; + +import { AppModule } from '@/app.module'; +import { OpensearchRepository } from '@/common/repositories'; +import { AuthService } from '@/domains/admin/auth/auth.service'; +import type { ProjectEntity } from '@/domains/admin/project/project/project.entity'; +import { ProjectService } from '@/domains/admin/project/project/project.service'; +import { + CreateRoleRequestDto, + UpdateRoleRequestDto, +} from '@/domains/admin/project/role/dtos/requests'; +import type { GetAllRolesResponseDto } from '@/domains/admin/project/role/dtos/responses'; +import type { GetAllRolesResponseRoleDto } from '@/domains/admin/project/role/dtos/responses/get-all-roles-response.dto'; +import { PermissionEnum } from '@/domains/admin/project/role/permission.enum'; +import { RoleService } from '@/domains/admin/project/role/role.service'; +import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; +import { TenantService } from '@/domains/admin/tenant/tenant.service'; +import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions'; + +describe('RoleController (integration)', () => { + let app: INestApplication; + + let dataSource: DataSource; + let authService: AuthService; + let tenantService: TenantService; + let projectService: ProjectService; + let _roleService: RoleService; + let configService: ConfigService; + let opensearchRepository: OpensearchRepository; + + let project: ProjectEntity; + let accessToken: string; + + beforeAll(async () => { + initializeTransactionalContext(); + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + + dataSource = module.get(getDataSourceToken()); + authService = module.get(AuthService); + tenantService = module.get(TenantService); + projectService = module.get(ProjectService); + _roleService = module.get(RoleService); + configService = module.get(ConfigService); + opensearchRepository = module.get(OpensearchRepository); + + await clearAllEntities(module); + if (configService.get('opensearch.use')) { + await opensearchRepository.deleteAllIndexes(); + } + + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + dto.password = '12345678'; + await tenantService.create(dto); + + project = await projectService.create({ + name: faker.lorem.words(), + description: faker.lorem.lines(1), + timezone: { + countryCode: 'KR', + name: 'Asia/Seoul', + offset: '+09:00', + }, + }); + + const { jwt } = await signInTestUser(dataSource, authService); + accessToken = jwt.accessToken; + }); + + describe('/admin/projects/:projectId/roles (POST)', () => { + it('should create a role', async () => { + const dto = new CreateRoleRequestDto(); + dto.name = 'TestRole'; + dto.permissions = [ + PermissionEnum.feedback_download_read, + PermissionEnum.feedback_update, + ]; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + + const listResponse = await request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .query({ + searchText: 'TestRole', + page: 1, + limit: 10, + }) + .expect(200); + + const roles = (listResponse.body as GetAllRolesResponseDto).roles; + expect(roles.length).toBeGreaterThan(0); + + const createdRole = roles.find( + (role: GetAllRolesResponseRoleDto) => role.name === 'TestRole', + ); + expect(createdRole).toBeDefined(); + expect(createdRole?.name).toBe('TestRole'); + expect(createdRole?.permissions).toEqual([ + 'feedback_download_read', + 'feedback_update', + ]); + }); + + it('should return 400 for empty role name', async () => { + const dto = new CreateRoleRequestDto(); + dto.permissions = [PermissionEnum.feedback_download_read]; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 400 for invalid permissions', async () => { + const dto = new CreateRoleRequestDto(); + dto.name = 'TestRole'; + dto.permissions = []; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new CreateRoleRequestDto(); + dto.name = 'TestRole'; + dto.permissions = [PermissionEnum.feedback_download_read]; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/roles`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/roles (GET)', () => { + beforeEach(async () => { + // 테스트용 역할 생성 + const dto = new CreateRoleRequestDto(); + dto.name = 'TestRoleForList'; + dto.permissions = [PermissionEnum.feedback_download_read]; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + }); + + it('should find roles by project id', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .query({ + searchText: 'TestRole', + page: 1, + limit: 10, + }) + .expect(200) + .then(({ body }: { body: GetAllRolesResponseDto }) => { + const responseBody = body; + expect(responseBody.roles.length).toBeGreaterThan(0); + expect(responseBody.roles[0]).toHaveProperty('id'); + expect(responseBody.roles[0]).toHaveProperty('name'); + expect(responseBody.roles[0]).toHaveProperty('permissions'); + }); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/roles`) + .query({ + page: 1, + limit: 10, + }) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/roles/:roleId (PUT)', () => { + let roleId: number; + + beforeAll(async () => { + const dto = new CreateRoleRequestDto(); + dto.name = 'TestRoleForUpdate'; + dto.permissions = [PermissionEnum.feedback_download_read]; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + + const response = await request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + const roles = (response.body as GetAllRolesResponseDto).roles; + const createdRole = roles.find( + (role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForUpdate', + ); + if (!createdRole) { + throw new Error('TestRoleForUpdate not found'); + } + roleId = createdRole.id; + }); + + it('should update role', async () => { + const dto = new UpdateRoleRequestDto(); + dto.name = 'UpdatedTestRole'; + dto.permissions = [PermissionEnum.feedback_download_read]; + + await request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/roles/${roleId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(204); + + await request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + .then(({ body }: { body: GetAllRolesResponseDto }) => { + const roles = body.roles; + const updatedRole = roles.find( + (role: GetAllRolesResponseRoleDto) => + role.name === 'UpdatedTestRole', + ); + if (!updatedRole) { + throw new Error('UpdatedTestRole not found'); + } + expect(updatedRole.name).toBe('UpdatedTestRole'); + expect(updatedRole.permissions).toEqual(['feedback_download_read']); + }); + }); + + it('should return 400 for empty role name', async () => { + const dto = new UpdateRoleRequestDto(); + dto.permissions = [PermissionEnum.feedback_download_read]; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/roles/${roleId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new UpdateRoleRequestDto(); + dto.name = 'UpdatedRole'; + dto.permissions = [PermissionEnum.feedback_download_read]; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/roles/${roleId}`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/roles/:roleId (DELETE)', () => { + let roleId: number; + + beforeAll(async () => { + const dto = new CreateRoleRequestDto(); + dto.name = 'TestRoleForDelete'; + dto.permissions = [PermissionEnum.feedback_download_read]; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + + const response = await request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + const roles = (response.body as GetAllRolesResponseDto).roles; + const createdRole = roles.find( + (role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForDelete', + ); + if (!createdRole) { + throw new Error('TestRoleForDelete not found'); + } + roleId = createdRole.id; + }); + + it('should delete role', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/roles/${roleId}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + // 삭제된 Role이 목록에 없는지 확인 + const response = await request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/roles`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + const roles = (response.body as GetAllRolesResponseDto).roles; + const deletedRole = roles.find( + (role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForDelete', + ); + expect(deletedRole).toBeUndefined(); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/roles/${roleId}`) + .expect(401); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/webhook.integration-spec.ts b/apps/api/integration-test/test-specs/webhook.integration-spec.ts new file mode 100644 index 000000000..e9fb16f0d --- /dev/null +++ b/apps/api/integration-test/test-specs/webhook.integration-spec.ts @@ -0,0 +1,560 @@ +/** + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import type { Server } from 'net'; +import { faker } from '@faker-js/faker'; +import type { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import request from 'supertest'; +import type { DataSource } from 'typeorm'; +import { initializeTransactionalContext } from 'typeorm-transactional'; + +import { AppModule } from '@/app.module'; +import { + EventStatusEnum, + EventTypeEnum, + WebhookStatusEnum, +} from '@/common/enums'; +import { OpensearchRepository } from '@/common/repositories'; +import { AuthService } from '@/domains/admin/auth/auth.service'; +import type { ProjectEntity } from '@/domains/admin/project/project/project.entity'; +import { ProjectService } from '@/domains/admin/project/project/project.service'; +import { + CreateWebhookRequestDto, + UpdateWebhookRequestDto, +} from '@/domains/admin/project/webhook/dtos/requests'; +import type { + GetWebhookByIdResponseDto, + GetWebhooksByProjectIdResponseDto, +} from '@/domains/admin/project/webhook/dtos/responses'; +import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; +import { TenantService } from '@/domains/admin/tenant/tenant.service'; +import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions'; + +describe('WebhookController (integration)', () => { + let app: INestApplication; + + let dataSource: DataSource; + let authService: AuthService; + let tenantService: TenantService; + let projectService: ProjectService; + let configService: ConfigService; + let opensearchRepository: OpensearchRepository; + + let project: ProjectEntity; + let accessToken: string; + + beforeAll(async () => { + initializeTransactionalContext(); + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + + dataSource = module.get(getDataSourceToken()); + authService = module.get(AuthService); + tenantService = module.get(TenantService); + projectService = module.get(ProjectService); + configService = module.get(ConfigService); + opensearchRepository = module.get(OpensearchRepository); + + await clearAllEntities(module); + if (configService.get('opensearch.use')) { + await opensearchRepository.deleteAllIndexes(); + } + + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + dto.password = '12345678'; + await tenantService.create(dto); + + project = await projectService.create({ + name: faker.lorem.words(), + description: faker.lorem.lines(1), + timezone: { + countryCode: 'KR', + name: 'Asia/Seoul', + offset: '+09:00', + }, + }); + + const { jwt } = await signInTestUser(dataSource, authService); + accessToken = jwt.accessToken; + }); + + describe('/admin/projects/:projectId/webhooks (POST)', () => { + it('should create a webhook', async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhook'; + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(201); + + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(200) + .then(({ body }: { body: GetWebhooksByProjectIdResponseDto }) => { + expect(body.items[0].name).toBe('TestWebhook'); + expect(body.items[0].url).toBe('https://example.com/webhook'); + expect(body.items[0].events).toHaveLength(1); + expect(body.items[0].status).toBe(WebhookStatusEnum.ACTIVE); + expect(body.items[0].createdAt).toBeDefined(); + }); + }); + + it('should return 400 for empty webhook name', async () => { + const dto = new CreateWebhookRequestDto(); + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 400 for invalid URL format', async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhook'; + dto.url = 'invalid-url'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 400 for empty events array', async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhook'; + dto.url = 'https://example.com/webhook'; + dto.events = []; + dto.status = WebhookStatusEnum.ACTIVE; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhook'; + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/webhooks (GET)', () => { + beforeEach(async () => { + // 테스트용 웹훅 생성 + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhookForList'; + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + }); + + it('should find webhooks by project id', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .query({ + searchText: 'TestWebhook', + page: 1, + limit: 10, + }) + .expect(200) + .then(({ body }: { body: GetWebhooksByProjectIdResponseDto }) => { + const responseBody = body; + expect(responseBody.items.length).toBeGreaterThan(0); + expect(responseBody.items[0]).toHaveProperty('id'); + expect(responseBody.items[0]).toHaveProperty('name'); + expect(responseBody.items[0]).toHaveProperty('url'); + expect(responseBody.items[0]).toHaveProperty('events'); + expect(responseBody.items[0]).toHaveProperty('status'); + expect(responseBody.items[0]).toHaveProperty('createdAt'); + }); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks`) + .query({ + page: 1, + limit: 10, + }) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/webhooks/:webhookId (GET)', () => { + let webhookId: number; + + beforeEach(async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhookForGet'; + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + webhookId = (response.body as { id: number }).id; + }); + + it('should find webhook by id', async () => { + const response = await request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + const body = response.body as GetWebhookByIdResponseDto[]; + expect(response.body).toBeDefined(); + expect(body[0].id).toBe(webhookId); + expect(body[0].name).toBe('TestWebhookForGet'); + expect(body[0].url).toBe('https://example.com/webhook'); + expect(body[0].events).toHaveLength(1); + expect(body[0].status).toBe(WebhookStatusEnum.ACTIVE); + expect(body[0].createdAt).toBeDefined(); + }); + + it('should return 200 for non-existent webhook', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks/999`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/webhooks/:webhookId (PUT)', () => { + let webhookId: number; + + beforeEach(async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhookForUpdate'; + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + webhookId = (response.body as { id: number }).id; + }); + + it('should update webhook', async () => { + const dto = new UpdateWebhookRequestDto(); + dto.name = 'UpdatedTestWebhook'; + dto.url = 'https://updated-example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + dto.token = null; + + await request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(200); + + const response = await request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + expect(response.body).toBeDefined(); + const body = response.body as GetWebhookByIdResponseDto[]; + expect(body[0].name).toBe('UpdatedTestWebhook'); + expect(body[0].url).toBe('https://updated-example.com/webhook'); + expect(body[0].events).toHaveLength(1); + expect(body[0].events[0].type).toBe(EventTypeEnum.FEEDBACK_CREATION); + expect(body[0].status).toBe(WebhookStatusEnum.ACTIVE); + }); + + it('should update webhook with empty name', async () => { + const dto = new UpdateWebhookRequestDto(); + dto.name = ''; + dto.url = 'https://updated-example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + dto.token = null; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(200); + }); + + it('should update webhook with invalid URL', async () => { + const dto = new UpdateWebhookRequestDto(); + dto.name = 'UpdatedWebhook'; + dto.url = 'invalid-url'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + dto.token = null; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(200); + }); + + it('should return 400 for non-existent webhook', async () => { + const dto = new UpdateWebhookRequestDto(); + dto.name = 'UpdatedWebhook'; + dto.url = 'https://updated-example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + dto.token = null; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/webhooks/999`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto) + .expect(400); + }); + + it('should return 401 when unauthorized', async () => { + const dto = new UpdateWebhookRequestDto(); + dto.name = 'UpdatedWebhook'; + dto.url = 'https://updated-example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + return request(app.getHttpServer() as Server) + .put(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .send(dto) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/webhooks/:webhookId (DELETE)', () => { + let webhookId: number; + + beforeEach(async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhookForDelete'; + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + webhookId = (response.body as { id: number }).id; + }); + + it('should delete webhook', async () => { + await request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + return request(app.getHttpServer() as Server) + .get(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + }); + + it('should return 500 when deleting non-existent webhook', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/webhooks/999`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(500); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .expect(401); + }); + }); + + describe('/admin/projects/:projectId/webhooks/:webhookId/test (POST)', () => { + let webhookId: number; + + beforeEach(async () => { + const dto = new CreateWebhookRequestDto(); + dto.name = 'TestWebhookForTest'; + dto.url = 'https://example.com/webhook'; + dto.events = [ + { + type: EventTypeEnum.FEEDBACK_CREATION, + status: EventStatusEnum.ACTIVE, + channelIds: [], + }, + ]; + dto.status = WebhookStatusEnum.ACTIVE; + + const response = await request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + webhookId = (response.body as { id: number }).id; + }); + + it('should return 404 for test webhook', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks/${webhookId}/test`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(404); + }); + + it('should return 404 for non-existent webhook test', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks/999/test`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(404); + }); + + it('should return 404 when unauthorized for test', async () => { + return request(app.getHttpServer() as Server) + .post(`/admin/projects/${project.id}/webhooks/${webhookId}/test`) + .expect(404); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/src/domains/admin/project/category/exceptions/category-not-found.exception.ts b/apps/api/src/domains/admin/project/category/exceptions/category-not-found.exception.ts index a793bf9b7..de4c48c51 100644 --- a/apps/api/src/domains/admin/project/category/exceptions/category-not-found.exception.ts +++ b/apps/api/src/domains/admin/project/category/exceptions/category-not-found.exception.ts @@ -13,11 +13,11 @@ * License for the specific language governing permissions and limitations * under the License. */ -import { BadRequestException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { ErrorCode } from '@ufb/shared'; -export class CategoryNotFoundException extends BadRequestException { +export class CategoryNotFoundException extends NotFoundException { constructor() { super({ code: ErrorCode.Category.CategoryNotFound, diff --git a/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts b/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts index d6b943104..2eb3ceac5 100644 --- a/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts +++ b/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts @@ -14,7 +14,7 @@ * under the License. */ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsEnum, IsString } from 'class-validator'; +import { IsArray, IsEnum, IsNotEmpty, IsString, IsUrl } from 'class-validator'; import { WebhookStatusEnum } from '@/common/enums'; import { TokenValidator } from '@/common/validators/token-validator'; @@ -24,10 +24,12 @@ import { EventDto } from '..'; export class CreateWebhookRequestDto { @ApiProperty() @IsString() + @IsNotEmpty() name: string; @ApiProperty() @IsString() + @IsUrl() url: string; @ApiProperty({ enum: WebhookStatusEnum }) From 4b4f90ca877f94ea827132c81d217f2017107a4b Mon Sep 17 00:00:00 2001 From: "jeehoon.choi" Date: Thu, 16 Oct 2025 12:39:10 +0900 Subject: [PATCH 2/7] remove comments --- apps/api/integration-test/test-specs/role.integration-spec.ts | 2 -- .../api/integration-test/test-specs/webhook.integration-spec.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/apps/api/integration-test/test-specs/role.integration-spec.ts b/apps/api/integration-test/test-specs/role.integration-spec.ts index 6ea36212a..7f4f71026 100644 --- a/apps/api/integration-test/test-specs/role.integration-spec.ts +++ b/apps/api/integration-test/test-specs/role.integration-spec.ts @@ -172,7 +172,6 @@ describe('RoleController (integration)', () => { describe('/admin/projects/:projectId/roles (GET)', () => { beforeEach(async () => { - // 테스트용 역할 생성 const dto = new CreateRoleRequestDto(); dto.name = 'TestRoleForList'; dto.permissions = [PermissionEnum.feedback_download_read]; @@ -329,7 +328,6 @@ describe('RoleController (integration)', () => { .set('Authorization', `Bearer ${accessToken}`) .expect(200); - // 삭제된 Role이 목록에 없는지 확인 const response = await request(app.getHttpServer() as Server) .get(`/admin/projects/${project.id}/roles`) .set('Authorization', `Bearer ${accessToken}`) diff --git a/apps/api/integration-test/test-specs/webhook.integration-spec.ts b/apps/api/integration-test/test-specs/webhook.integration-spec.ts index e9fb16f0d..f687c68b8 100644 --- a/apps/api/integration-test/test-specs/webhook.integration-spec.ts +++ b/apps/api/integration-test/test-specs/webhook.integration-spec.ts @@ -208,7 +208,6 @@ describe('WebhookController (integration)', () => { describe('/admin/projects/:projectId/webhooks (GET)', () => { beforeEach(async () => { - // 테스트용 웹훅 생성 const dto = new CreateWebhookRequestDto(); dto.name = 'TestWebhookForList'; dto.url = 'https://example.com/webhook'; From 341f7691946cecd1d9b1924d698457cab3755662 Mon Sep 17 00:00:00 2001 From: "jeehoon.choi" Date: Thu, 16 Oct 2025 12:59:31 +0900 Subject: [PATCH 3/7] remove test --- .../test-specs/channel.integration-spec.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/api/integration-test/test-specs/channel.integration-spec.ts b/apps/api/integration-test/test-specs/channel.integration-spec.ts index 29eb6d84e..cc181393a 100644 --- a/apps/api/integration-test/test-specs/channel.integration-spec.ts +++ b/apps/api/integration-test/test-specs/channel.integration-spec.ts @@ -244,13 +244,6 @@ describe('ChannelController (integration)', () => { }); }); - it('should return 404 when deleting non-existent channel', async () => { - await request(app.getHttpServer() as Server) - .delete(`/admin/projects/${project.id}/channels/999`) - .set('Authorization', `Bearer ${accessToken}`) - .expect(404); - }); - it('should return 401 when unauthorized', async () => { await request(app.getHttpServer() as Server) .delete(`/admin/projects/${project.id}/channels/1`) From 136b266ca41b2b5892704fa3710d6ca96a730a48 Mon Sep 17 00:00:00 2001 From: "jeehoon.choi" Date: Thu, 16 Oct 2025 15:57:56 +0900 Subject: [PATCH 4/7] fix test --- .../test-specs/auth.integration-spec.ts | 45 ------------------- .../test-specs/issue.integration-spec.ts | 4 +- .../test-specs/member.integration-spec.ts | 21 --------- .../test-specs/webhook.integration-spec.ts | 36 ++------------- .../dtos/requests/create-role-request.dto.ts | 3 +- .../admin/project/webhook/exceptions/index.ts | 1 + .../exceptions/webhook-not-found.exception.ts | 27 +++++++++++ .../admin/project/webhook/webhook.service.ts | 24 +++++----- packages/ufb-shared/src/error-code.enum.ts | 1 + 9 files changed, 51 insertions(+), 111 deletions(-) create mode 100644 apps/api/src/domains/admin/project/webhook/exceptions/webhook-not-found.exception.ts diff --git a/apps/api/integration-test/test-specs/auth.integration-spec.ts b/apps/api/integration-test/test-specs/auth.integration-spec.ts index f5b6e53e1..612a7da03 100644 --- a/apps/api/integration-test/test-specs/auth.integration-spec.ts +++ b/apps/api/integration-test/test-specs/auth.integration-spec.ts @@ -31,7 +31,6 @@ import { EmailUserSignInRequestDto, EmailUserSignUpRequestDto, EmailVerificationCodeRequestDto, - EmailVerificationMailingRequestDto, InvitationUserSignUpRequestDto, } from '@/domains/admin/auth/dtos/requests'; import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; @@ -73,28 +72,6 @@ describe('AuthController (integration)', () => { await tenantService.create(dto); }); - describe('/admin/auth/email/code (POST)', () => { - it('should return 400 for invalid email format', async () => { - const dto = new EmailVerificationMailingRequestDto(); - dto.email = 'invalid-email'; - - return request(app.getHttpServer() as Server) - .post('/admin/auth/email/code') - .send(dto) - .expect(500); - }); - - it('should return 400 for empty email', async () => { - const dto = new EmailVerificationMailingRequestDto(); - dto.email = ''; - - return request(app.getHttpServer() as Server) - .post('/admin/auth/email/code') - .send(dto) - .expect(500); - }); - }); - describe('/admin/auth/email/code/verify (POST)', () => { it('should verify email code successfully', async () => { const dto = new EmailVerificationCodeRequestDto(); @@ -106,28 +83,6 @@ describe('AuthController (integration)', () => { .send(dto) .expect(200); }); - - it('should return 400 for invalid verification code', async () => { - const dto = new EmailVerificationCodeRequestDto(); - dto.email = faker.internet.email(); - dto.code = 'invalid-code'; - - return request(app.getHttpServer() as Server) - .post('/admin/auth/email/code/verify') - .send(dto) - .expect(200); - }); - - it('should return 400 for expired verification code', async () => { - const dto = new EmailVerificationCodeRequestDto(); - dto.email = faker.internet.email(); - dto.code = 'expired-code'; - - return request(app.getHttpServer() as Server) - .post('/admin/auth/email/code/verify') - .send(dto) - .expect(200); - }); }); describe('/admin/auth/signUp/email (POST)', () => { diff --git a/apps/api/integration-test/test-specs/issue.integration-spec.ts b/apps/api/integration-test/test-specs/issue.integration-spec.ts index 7451cbef9..98a10bd86 100644 --- a/apps/api/integration-test/test-specs/issue.integration-spec.ts +++ b/apps/api/integration-test/test-specs/issue.integration-spec.ts @@ -254,7 +254,7 @@ describe('IssueController (integration)', () => { }); describe('Issue validation tests', () => { - it('should return 404 when updating non-existent issue', async () => { + it('should return 400 when updating non-existent issue', async () => { await request(app.getHttpServer() as Server) .put(`/admin/projects/${project.id}/issues/999`) .set('Authorization', `Bearer ${accessToken}`) @@ -265,7 +265,7 @@ describe('IssueController (integration)', () => { .expect(400); }); - it('should return 404 when getting non-existent issue', async () => { + it('should return 400 when getting non-existent issue', async () => { return request(app.getHttpServer() as Server) .get(`/admin/projects/${project.id}/issues/999`) .set('Authorization', `Bearer ${accessToken}`) diff --git a/apps/api/integration-test/test-specs/member.integration-spec.ts b/apps/api/integration-test/test-specs/member.integration-spec.ts index eeb6b6b30..ded90ac57 100644 --- a/apps/api/integration-test/test-specs/member.integration-spec.ts +++ b/apps/api/integration-test/test-specs/member.integration-spec.ts @@ -275,21 +275,6 @@ describe('MemberController (integration)', () => { }); describe('/admin/projects/:projectId/members/:memberId (GET)', () => { - let memberId: number; - - beforeEach(async () => { - const dto = new CreateMemberRequestDto(); - dto.userId = user.id; - dto.roleId = role.id; - - const response = await request(app.getHttpServer() as Server) - .post(`/admin/projects/${project.id}/members`) - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - - memberId = (response.body as { id: number }).id; - }); - afterEach(async () => { await dataSource.query('DELETE FROM members WHERE role_id = ?', [ role.id, @@ -302,12 +287,6 @@ describe('MemberController (integration)', () => { .set('Authorization', `Bearer ${accessToken}`) .expect(404); }); - - it('should return 401 when unauthorized', async () => { - return request(app.getHttpServer() as Server) - .get(`/admin/projects/${project.id}/members/${memberId}`) - .expect(404); - }); }); describe('/admin/projects/:projectId/members/:memberId (PUT)', () => { diff --git a/apps/api/integration-test/test-specs/webhook.integration-spec.ts b/apps/api/integration-test/test-specs/webhook.integration-spec.ts index f687c68b8..371d837b1 100644 --- a/apps/api/integration-test/test-specs/webhook.integration-spec.ts +++ b/apps/api/integration-test/test-specs/webhook.integration-spec.ts @@ -299,13 +299,6 @@ describe('WebhookController (integration)', () => { expect(body[0].createdAt).toBeDefined(); }); - it('should return 200 for non-existent webhook', async () => { - return request(app.getHttpServer() as Server) - .get(`/admin/projects/${project.id}/webhooks/999`) - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - }); - it('should return 401 when unauthorized', async () => { return request(app.getHttpServer() as Server) .get(`/admin/projects/${project.id}/webhooks/${webhookId}`) @@ -392,28 +385,7 @@ describe('WebhookController (integration)', () => { .expect(200); }); - it('should update webhook with invalid URL', async () => { - const dto = new UpdateWebhookRequestDto(); - dto.name = 'UpdatedWebhook'; - dto.url = 'invalid-url'; - dto.events = [ - { - type: EventTypeEnum.FEEDBACK_CREATION, - status: EventStatusEnum.ACTIVE, - channelIds: [], - }, - ]; - dto.status = WebhookStatusEnum.ACTIVE; - dto.token = null; - - return request(app.getHttpServer() as Server) - .put(`/admin/projects/${project.id}/webhooks/${webhookId}`) - .set('Authorization', `Bearer ${accessToken}`) - .send(dto) - .expect(200); - }); - - it('should return 400 for non-existent webhook', async () => { + it('should return 404 for non-existent webhook', async () => { const dto = new UpdateWebhookRequestDto(); dto.name = 'UpdatedWebhook'; dto.url = 'https://updated-example.com/webhook'; @@ -431,7 +403,7 @@ describe('WebhookController (integration)', () => { .put(`/admin/projects/${project.id}/webhooks/999`) .set('Authorization', `Bearer ${accessToken}`) .send(dto) - .expect(400); + .expect(404); }); it('should return 401 when unauthorized', async () => { @@ -490,11 +462,11 @@ describe('WebhookController (integration)', () => { .expect(200); }); - it('should return 500 when deleting non-existent webhook', async () => { + it('should return 404 when deleting non-existent webhook', async () => { return request(app.getHttpServer() as Server) .delete(`/admin/projects/${project.id}/webhooks/999`) .set('Authorization', `Bearer ${accessToken}`) - .expect(500); + .expect(404); }); it('should return 401 when unauthorized', async () => { diff --git a/apps/api/src/domains/admin/project/role/dtos/requests/create-role-request.dto.ts b/apps/api/src/domains/admin/project/role/dtos/requests/create-role-request.dto.ts index ad68ad644..9dd7946ce 100644 --- a/apps/api/src/domains/admin/project/role/dtos/requests/create-role-request.dto.ts +++ b/apps/api/src/domains/admin/project/role/dtos/requests/create-role-request.dto.ts @@ -14,7 +14,7 @@ * under the License. */ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsString } from 'class-validator'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { ArrayDistinct } from '@/common/validators'; import { PermissionEnum } from '../../permission.enum'; @@ -27,5 +27,6 @@ export class CreateRoleRequestDto { @ApiProperty() @IsEnum(PermissionEnum, { each: true }) @ArrayDistinct() + @IsNotEmpty() permissions: PermissionEnum[]; } diff --git a/apps/api/src/domains/admin/project/webhook/exceptions/index.ts b/apps/api/src/domains/admin/project/webhook/exceptions/index.ts index ef7c309b6..c0c77304a 100644 --- a/apps/api/src/domains/admin/project/webhook/exceptions/index.ts +++ b/apps/api/src/domains/admin/project/webhook/exceptions/index.ts @@ -14,3 +14,4 @@ * under the License. */ export { WebhookAlreadyExistsException } from './webhook-already-exists.exception'; +export { WebhookNotFoundException } from './webhook-not-found.exception'; diff --git a/apps/api/src/domains/admin/project/webhook/exceptions/webhook-not-found.exception.ts b/apps/api/src/domains/admin/project/webhook/exceptions/webhook-not-found.exception.ts new file mode 100644 index 000000000..d8a5a5fc4 --- /dev/null +++ b/apps/api/src/domains/admin/project/webhook/exceptions/webhook-not-found.exception.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { NotFoundException } from '@nestjs/common'; + +import { ErrorCode } from '@ufb/shared'; + +export class WebhookNotFoundException extends NotFoundException { + constructor() { + super({ + code: ErrorCode.Webhook.WebhookNotFound, + message: 'Webhook not found', + }); + } +} diff --git a/apps/api/src/domains/admin/project/webhook/webhook.service.ts b/apps/api/src/domains/admin/project/webhook/webhook.service.ts index 6b30eec86..6d0ee5102 100644 --- a/apps/api/src/domains/admin/project/webhook/webhook.service.ts +++ b/apps/api/src/domains/admin/project/webhook/webhook.service.ts @@ -23,7 +23,10 @@ import { ChannelEntity } from '../../channel/channel/channel.entity'; import type { EventDto } from './dtos'; import { CreateWebhookDto, UpdateWebhookDto } from './dtos'; import { EventEntity } from './event.entity'; -import { WebhookAlreadyExistsException } from './exceptions'; +import { + WebhookAlreadyExistsException, + WebhookNotFoundException, +} from './exceptions'; import { WebhookEntity } from './webhook.entity'; @Injectable() @@ -116,11 +119,12 @@ export class WebhookService { @Transactional() async update(dto: UpdateWebhookDto): Promise { - const webhook = - (await this.repository.findOne({ - where: { id: dto.id }, - relations: ['events'], - })) ?? new WebhookEntity(); + const webhook = await this.repository.findOne({ + where: { id: dto.id }, + relations: ['events'], + }); + + if (!webhook) throw new WebhookNotFoundException(); if ( await this.repository.findOne({ @@ -159,10 +163,10 @@ export class WebhookService { @Transactional() async delete(webhookId: number) { - const webhook = - (await this.repository.findOne({ - where: { id: webhookId }, - })) ?? new WebhookEntity(); + const webhook = await this.repository.findOne({ + where: { id: webhookId }, + }); + if (!webhook) throw new WebhookNotFoundException(); await this.repository.remove(webhook); } diff --git a/packages/ufb-shared/src/error-code.enum.ts b/packages/ufb-shared/src/error-code.enum.ts index 6d9a26be1..996f9bbc8 100644 --- a/packages/ufb-shared/src/error-code.enum.ts +++ b/packages/ufb-shared/src/error-code.enum.ts @@ -102,6 +102,7 @@ const Opensearch = { const Webhook = { WebhookAlreadyExists: 'WebhookAlreadyExists', + WebhookNotFound: 'WebhookNotFound', }; export const ErrorCode = { From c832bfa5a33515747ba11315c2547e70f72488a5 Mon Sep 17 00:00:00 2001 From: "jeehoon.choi" Date: Thu, 16 Oct 2025 16:14:20 +0900 Subject: [PATCH 5/7] fix test --- .../project/webhook/webhook.service.spec.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/api/src/domains/admin/project/webhook/webhook.service.spec.ts b/apps/api/src/domains/admin/project/webhook/webhook.service.spec.ts index 93167995b..ca9cf1a2f 100644 --- a/apps/api/src/domains/admin/project/webhook/webhook.service.spec.ts +++ b/apps/api/src/domains/admin/project/webhook/webhook.service.spec.ts @@ -30,7 +30,10 @@ import { getRandomEnumValue, TestConfig } from '@/test-utils/util-functions'; import { WebhookServiceProviders } from '../../../../test-utils/providers/webhook.service.provider'; import { ChannelEntity } from '../../channel/channel/channel.entity'; import type { CreateWebhookDto, UpdateWebhookDto } from './dtos'; -import { WebhookAlreadyExistsException } from './exceptions'; +import { + WebhookAlreadyExistsException, + WebhookNotFoundException, +} from './exceptions'; import { WebhookEntity } from './webhook.entity'; import { WebhookService } from './webhook.service'; @@ -453,18 +456,17 @@ describe('webhook service', () => { expect(webhookRepo.remove).toHaveBeenCalledWith(webhookFixture); }); - it('should delete webhook even when webhook does not exist', async () => { + it('should throw WebhookNotFoundException when webhook does not exist', async () => { const webhookId = faker.number.int(); - const emptyWebhook = new WebhookEntity(); jest.spyOn(webhookRepo, 'findOne').mockResolvedValue(null); - jest.spyOn(webhookRepo, 'remove').mockResolvedValue(emptyWebhook); - await webhookService.delete(webhookId); + await expect(webhookService.delete(webhookId)).rejects.toThrow( + new WebhookNotFoundException(), + ); expect(webhookRepo.findOne).toHaveBeenCalledWith({ where: { id: webhookId }, }); - expect(webhookRepo.remove).toHaveBeenCalledWith(emptyWebhook); }); }); @@ -638,7 +640,7 @@ describe('webhook service', () => { }); describe('update - additional edge cases', () => { - it('should handle updating non-existent webhook', async () => { + it('should throw WebhookNotFoundException when updating non-existent webhook', async () => { const dto: UpdateWebhookDto = createUpdateWebhookDto({ id: faker.number.int(), }); @@ -647,10 +649,9 @@ describe('webhook service', () => { jest.spyOn(webhookRepo, 'findOne').mockResolvedValueOnce(null); jest.spyOn(webhookRepo, 'save').mockResolvedValue(webhookFixture); - const webhook = await webhookService.update(dto); - - expect(webhook).toBeDefined(); - expect(webhookRepo.save).toHaveBeenCalled(); + await expect(webhookService.update(dto)).rejects.toThrow( + new WebhookNotFoundException(), + ); }); it('should handle updating webhook with same name but different ID', async () => { From 54742dc37e25cbde7f42768dc651f80b95019791 Mon Sep 17 00:00:00 2001 From: "jeehoon.choi" Date: Thu, 16 Oct 2025 16:27:28 +0900 Subject: [PATCH 6/7] fix test --- .../test-specs/webhook.integration-spec.ts | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/apps/api/integration-test/test-specs/webhook.integration-spec.ts b/apps/api/integration-test/test-specs/webhook.integration-spec.ts index 371d837b1..37a7a2902 100644 --- a/apps/api/integration-test/test-specs/webhook.integration-spec.ts +++ b/apps/api/integration-test/test-specs/webhook.integration-spec.ts @@ -476,51 +476,6 @@ describe('WebhookController (integration)', () => { }); }); - describe('/admin/projects/:projectId/webhooks/:webhookId/test (POST)', () => { - let webhookId: number; - - beforeEach(async () => { - const dto = new CreateWebhookRequestDto(); - dto.name = 'TestWebhookForTest'; - dto.url = 'https://example.com/webhook'; - dto.events = [ - { - type: EventTypeEnum.FEEDBACK_CREATION, - status: EventStatusEnum.ACTIVE, - channelIds: [], - }, - ]; - dto.status = WebhookStatusEnum.ACTIVE; - - const response = await request(app.getHttpServer() as Server) - .post(`/admin/projects/${project.id}/webhooks`) - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - - webhookId = (response.body as { id: number }).id; - }); - - it('should return 404 for test webhook', async () => { - return request(app.getHttpServer() as Server) - .post(`/admin/projects/${project.id}/webhooks/${webhookId}/test`) - .set('Authorization', `Bearer ${accessToken}`) - .expect(404); - }); - - it('should return 404 for non-existent webhook test', async () => { - return request(app.getHttpServer() as Server) - .post(`/admin/projects/${project.id}/webhooks/999/test`) - .set('Authorization', `Bearer ${accessToken}`) - .expect(404); - }); - - it('should return 404 when unauthorized for test', async () => { - return request(app.getHttpServer() as Server) - .post(`/admin/projects/${project.id}/webhooks/${webhookId}/test`) - .expect(404); - }); - }); - afterAll(async () => { const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); From 455b5d50458ce15f9961476b3a91aa33009d77c0 Mon Sep 17 00:00:00 2001 From: "jeehoon.choi" Date: Thu, 16 Oct 2025 16:38:54 +0900 Subject: [PATCH 7/7] fix test --- .../project/webhook/dtos/requests/create-webhook-request.dto.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts b/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts index 2eb3ceac5..c0350f97b 100644 --- a/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts +++ b/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts @@ -38,6 +38,7 @@ export class CreateWebhookRequestDto { @ApiProperty({ type: [EventDto] }) @IsArray() + @IsNotEmpty() events: EventDto[]; @ApiProperty({ nullable: true, type: String })