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..612a7da03 --- /dev/null +++ b/apps/api/integration-test/test-specs/auth.integration-spec.ts @@ -0,0 +1,233 @@ +/** + * 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, + 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/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); + }); + }); + + 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..cc181393a 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,45 @@ describe('ChannelController (integration)', () => { expect(body.items.length).toBe(0); }); }); + + 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..98a10bd86 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 400 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 400 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..ded90ac57 --- /dev/null +++ b/apps/api/integration-test/test-specs/member.integration-spec.ts @@ -0,0 +1,427 @@ +/** + * 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)', () => { + 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); + }); + }); + + 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..7f4f71026 --- /dev/null +++ b/apps/api/integration-test/test-specs/role.integration-spec.ts @@ -0,0 +1,357 @@ +/** + * 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); + + 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..37a7a2902 --- /dev/null +++ b/apps/api/integration-test/test-specs/webhook.integration-spec.ts @@ -0,0 +1,486 @@ +/** + * 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 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 return 404 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(404); + }); + + 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 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(404); + }); + + it('should return 401 when unauthorized', async () => { + return request(app.getHttpServer() as Server) + .delete(`/admin/projects/${project.id}/webhooks/${webhookId}`) + .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/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/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/dtos/requests/create-webhook-request.dto.ts b/apps/api/src/domains/admin/project/webhook/dtos/requests/create-webhook-request.dto.ts index d6b943104..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 @@ -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 }) @@ -36,6 +38,7 @@ export class CreateWebhookRequestDto { @ApiProperty({ type: [EventDto] }) @IsArray() + @IsNotEmpty() events: EventDto[]; @ApiProperty({ nullable: true, type: String }) 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.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 () => { 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 275fed8d0..cdfef28f6 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 = {