first commit
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s

This commit is contained in:
SDI
2026-07-15 18:05:12 +09:00
commit 12e4f17b62
4633 changed files with 817125 additions and 0 deletions
@@ -0,0 +1,25 @@
/**
* 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 mysql from 'mysql2/promise';
export async function createConnection() {
return await mysql.createConnection({
host: '127.0.0.1',
port: 13307,
user: 'root',
password: 'userfeedback',
});
}
+66
View File
@@ -0,0 +1,66 @@
/**
* 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 { join } from 'path';
import { createConnection } from 'typeorm';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
import { createConnection as connect } from './database-utils';
process.env.NODE_ENV = 'test';
process.env.MYSQL_PRIMARY_URL =
'mysql://root:userfeedback@localhost:13307/integration';
process.env.MYSQL_SECONDARY_URLS = JSON.stringify([
'mysql://root:userfeedback@localhost:13307/integration',
]);
process.env.MASTER_API_KEY = 'master-api-key';
process.env.AUTO_FEEDBACK_DELETION_ENABLED = 'true';
process.env.AUTO_FEEDBACK_DELETION_PERIOD_DAYS = '30';
async function createTestDatabase() {
const connection = await connect();
await connection.query(`DROP DATABASE IF EXISTS integration;`);
await connection.query(`CREATE DATABASE IF NOT EXISTS integration;`);
await connection.end();
}
async function runMigrations() {
const connection = await createConnection({
type: 'mysql',
host: '127.0.0.1',
port: 13307,
username: 'root',
password: 'userfeedback',
database: 'integration',
migrations: [
join(
__dirname,
'../src/configs/modules/typeorm-config/migrations/*.{ts,js}',
),
],
migrationsTableName: 'migrations',
namingStrategy: new SnakeNamingStrategy(),
timezone: '+00:00',
});
await connection.runMigrations();
await connection.close();
}
export default async () => {
await createTestDatabase();
await runMigrations();
};
@@ -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 { createConnection as connect } from './database-utils';
async function dropTestDatabase() {
const connection = await connect();
await connection.query(`DROP DATABASE IF EXISTS integration;`);
await connection.end();
}
export default async () => {
await dropTestDatabase();
};
@@ -0,0 +1,19 @@
{
"displayName": "api-integration",
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": ["<rootDir>/../src/$1"]
},
"testRegex": ".integration-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": ["node_modules/(?!@faker-js|uuid)"],
"setupFilesAfterEnv": [
"<rootDir>/../integration-test/jest-integration.setup.ts"
],
"globalSetup": "<rootDir>/../integration-test/global.setup.ts",
"globalTeardown": "<rootDir>/../integration-test/global.teardown.ts"
}
@@ -0,0 +1,20 @@
/**
* 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.
*/
jest.mock('@nestjs-modules/mailer/dist/adapters/handlebars.adapter', () => {
return {
HandlebarsAdapter: jest.fn(),
};
});
@@ -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();
});
});
@@ -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();
});
});
@@ -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();
});
});
@@ -0,0 +1,294 @@
/**
* 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 {
FieldFormatEnum,
FieldPropertyEnum,
FieldStatusEnum,
} from '@/common/enums';
import { OpensearchRepository } from '@/common/repositories';
import { AuthService } from '@/domains/admin/auth/auth.service';
import {
CreateChannelRequestDto,
CreateChannelRequestFieldDto,
FindChannelsByProjectIdRequestDto,
UpdateChannelFieldsRequestDto,
UpdateChannelRequestDto,
UpdateChannelRequestFieldDto,
} from '@/domains/admin/channel/channel/dtos/requests';
import type {
FindChannelByIdResponseDto,
FindChannelsByProjectIdResponseDto,
} from '@/domains/admin/channel/channel/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('ChannelController (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/channels (POST)', () => {
it('should create a channel', async () => {
const dto = new CreateChannelRequestDto();
dto.name = 'TestChannel';
const fieldDto = new CreateChannelRequestFieldDto();
fieldDto.name = 'TestField';
fieldDto.key = 'testField';
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(201);
});
});
describe('/admin/projects/:projectId/channels (GET)', () => {
it('should find channels by project id', async () => {
const dto = new FindChannelsByProjectIdRequestDto();
dto.searchText = 'TestChannel';
dto.page = 1;
dto.limit = 10;
return request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/channels`)
.set('Authorization', `Bearer ${accessToken}`)
.query(dto)
.expect(200)
.then(({ body }: { body: FindChannelsByProjectIdResponseDto }) => {
expect(body.items.length).toBe(1);
expect(body.items[0].name).toBe('TestChannel');
});
});
});
describe('/admin/projects/:projectId/channels/:channelId (GET)', () => {
it('should find channel by id', async () => {
return request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/channels/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
expect(body.name).toBe('TestChannel');
});
});
});
describe('/admin/projects/:projectId/channels/:channelId (PUT)', () => {
it('should update channel', async () => {
const dto = new UpdateChannelRequestDto();
dto.name = 'TestChannelUpdated';
await request(app.getHttpServer() as Server)
.put(`/admin/projects/${project.id}/channels/1`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto)
.expect(200);
await request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/channels/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
expect(body.name).toBe('TestChannelUpdated');
});
});
});
describe('/admin/projects/:projectId/channels/:channelId/fields (PUT)', () => {
it('should update channel fields', async () => {
const dto = new UpdateChannelFieldsRequestDto();
const fieldDto = new UpdateChannelRequestFieldDto();
fieldDto.id = 5;
fieldDto.format = FieldFormatEnum.text;
fieldDto.key = 'testField';
fieldDto.name = 'TestFieldUpdated';
dto.fields = [fieldDto];
await request(app.getHttpServer() as Server)
.put(`/admin/projects/${project.id}/channels/1/fields`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto)
.expect(200);
await request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/channels/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
expect(body.fields.length).toBe(5);
expect(body.fields[4].name).toBe('TestFieldUpdated');
});
});
it('should return 400 error when update channel field key with special character', async () => {
const dto = new UpdateChannelFieldsRequestDto();
const fieldDto = new UpdateChannelRequestFieldDto();
fieldDto.id = 5;
fieldDto.format = FieldFormatEnum.text;
fieldDto.key = 'testField!';
fieldDto.name = 'testField!';
dto.fields = [fieldDto];
await request(app.getHttpServer() as Server)
.put(`/admin/projects/${project.id}/channels/1/fields`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto)
.expect(400);
});
});
describe('/admin/projects/:projectId/channels/:channelId (DELETE)', () => {
it('should delete channel', async () => {
await request(app.getHttpServer() as Server)
.delete(`/admin/projects/${project.id}/channels/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const dto = new FindChannelsByProjectIdRequestDto();
dto.page = 1;
dto.limit = 10;
return request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/channels`)
.set('Authorization', `Bearer ${accessToken}`)
.query(dto)
.expect(200)
.then(({ body }: { body: FindChannelsByProjectIdResponseDto }) => {
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 () => {
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await delay(500);
await app.close();
});
});
@@ -0,0 +1,420 @@
/**
* 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 type { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
import type { Client } from '@opensearch-project/opensearch';
import { DateTime } from 'luxon';
import request from 'supertest';
import type { DataSource, Repository } from 'typeorm';
import { initializeTransactionalContext } from 'typeorm-transactional';
import { AppModule } from '@/app.module';
import { FieldFormatEnum, QueryV2ConditionsEnum } from '@/common/enums';
import { OpensearchRepository } from '@/common/repositories';
import { AuthService } from '@/domains/admin/auth/auth.service';
import { ChannelEntity } from '@/domains/admin/channel/channel/channel.entity';
import { ChannelService } from '@/domains/admin/channel/channel/channel.service';
import { FieldEntity } from '@/domains/admin/channel/field/field.entity';
import type { CreateFeedbackDto } from '@/domains/admin/feedback/dtos';
import type { FindFeedbacksByChannelIdRequestDtoV2 } from '@/domains/admin/feedback/dtos/requests/find-feedbacks-by-channel-id-request-v2.dto';
import type { FindFeedbacksByChannelIdResponseDto } from '@/domains/admin/feedback/dtos/responses';
import { FeedbackService } from '@/domains/admin/feedback/feedback.service';
import { ProjectEntity } from '@/domains/admin/project/project/project.entity';
import { ProjectService } from '@/domains/admin/project/project/project.service';
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
import { TenantService } from '@/domains/admin/tenant/tenant.service';
import { getRandomValue } from '@/test-utils/fixtures';
import {
clearAllEntities,
clearEntities,
createChannel,
createProject,
createTenant,
signInTestUser,
} from '@/test-utils/util-functions';
describe('FeedbackController (integration)', () => {
let app: INestApplication;
let dataSource: DataSource;
let authService: AuthService;
let tenantService: TenantService;
let projectService: ProjectService;
let channelService: ChannelService;
let feedbackService: FeedbackService;
let configService: ConfigService;
let tenantRepo: Repository<TenantEntity>;
let projectRepo: Repository<ProjectEntity>;
let channelRepo: Repository<ChannelEntity>;
let fieldRepo: Repository<FieldEntity>;
let osService: Client;
let opensearchRepository: OpensearchRepository;
let project: ProjectEntity;
let channel: ChannelEntity;
let fields: FieldEntity[];
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);
channelService = module.get(ChannelService);
feedbackService = module.get(FeedbackService);
configService = module.get(ConfigService);
tenantRepo = module.get(getRepositoryToken(TenantEntity));
projectRepo = module.get(getRepositoryToken(ProjectEntity));
channelRepo = module.get(getRepositoryToken(ChannelEntity));
fieldRepo = module.get(getRepositoryToken(FieldEntity));
osService = module.get<Client>('OPENSEARCH_CLIENT');
opensearchRepository = module.get(OpensearchRepository);
await clearAllEntities(module);
if (configService.get('opensearch.use')) {
await opensearchRepository.deleteAllIndexes();
}
await createTenant(tenantService);
project = await createProject(projectService);
const { id: channelId } = await createChannel(channelService, project);
channel = await channelService.findById({ channelId });
fields = await fieldRepo.find({
where: { channel: { id: channel.id } },
relations: { options: true },
});
const { jwt } = await signInTestUser(dataSource, authService);
accessToken = jwt.accessToken;
});
describe('/admin/projects/:projectId/channels/:channelId/feedbacks (POST)', () => {
it('should create random feedbacks', async () => {
const dto: Record<string, string | number | string[] | number[]> = {};
fields
.filter(
({ key }) =>
key !== 'id' &&
key !== 'issues' &&
key !== 'createdAt' &&
key !== 'updatedAt',
)
.forEach(({ key, format, options }) => {
dto[key] = getRandomValue(format, options);
});
return request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
.send(dto)
.expect(201)
.then(
async ({
body,
}: {
body: Record<string, any> & { issueNames?: string[] };
}) => {
expect(body.id).toBeDefined();
if (configService.get('opensearch.use')) {
const esResult = await osService.get({
id: body.id as string,
index: channel.id.toString(),
});
['id', 'createdAt', 'updatedAt'].forEach(
(field) => delete esResult.body._source?.[field],
);
expect(dto).toMatchObject(esResult.body._source ?? {});
} else {
const feedback = await feedbackService.findById({
channelId: channel.id,
feedbackId: body.id as number,
});
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
(field) => delete feedback[field],
);
expect(dto).toMatchObject(feedback);
}
},
);
});
});
describe('/admin/projects/:projectId/channels/:channelId/feedbacks/search (POST)', () => {
it('should return all searched feedbacks', async () => {
const dto: CreateFeedbackDto = {
channelId: channel.id,
data: {},
};
let availableFieldKey = '';
fields
.filter(
({ key }) =>
key !== 'id' &&
key !== 'issues' &&
key !== 'createdAt' &&
key !== 'updatedAt',
)
.forEach(({ key, format, options }) => {
dto.data[key] = getRandomValue(format, options);
availableFieldKey = key;
});
dto.data[availableFieldKey] = 'test';
await feedbackService.create(dto);
const keywordField = fields.find(
({ format }) => format === FieldFormatEnum.keyword,
);
if (!keywordField) return;
const findFeedbackDto: FindFeedbacksByChannelIdRequestDtoV2 = {
queries: [
{
key: availableFieldKey,
value: 'test',
condition: QueryV2ConditionsEnum.IS,
},
],
operator: 'AND',
limit: 10,
page: 1,
};
return request(app.getHttpServer() as Server)
.post(
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/search`,
)
.set('Authorization', `Bearer ${accessToken}`)
.send(findFeedbackDto)
.expect(201)
.then(({ body }: { body: FindFeedbacksByChannelIdResponseDto }) => {
expect(body.meta.itemCount).toEqual(1);
});
});
});
describe('/admin/projects/:projectId/channels/:channelId/feedbacks/:feedbackId (PUT)', () => {
it('should update a feedback', async () => {
const dto: CreateFeedbackDto = {
channelId: channel.id,
data: {},
};
let availableFieldKey = '';
fields
.filter(
({ key }) =>
key !== 'id' &&
key !== 'issues' &&
key !== 'createdAt' &&
key !== 'updatedAt',
)
.forEach(({ key, format, options }) => {
dto.data[key] = getRandomValue(format, options);
availableFieldKey = key;
});
const feedback = await feedbackService.create(dto);
return request(app.getHttpServer() as Server)
.put(
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/${feedback.id}`,
)
.set('Authorization', `Bearer ${accessToken}`)
.send({
[availableFieldKey]: 'test',
})
.expect(200)
.then(async () => {
if (configService.get('opensearch.use')) {
const esResult = await osService.get({
id: feedback.id.toString(),
index: channel.id.toString(),
});
['id', 'createdAt', 'updatedAt'].forEach(
(field) => delete esResult.body._source?.[field],
);
dto.data[availableFieldKey] = 'test';
expect(dto.data).toMatchObject(esResult.body._source ?? {});
} else {
const updatedFeedback = await feedbackService.findById({
channelId: channel.id,
feedbackId: feedback.id,
});
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
(field) => delete updatedFeedback[field],
);
dto.data[availableFieldKey] = 'test';
expect(dto.data).toMatchObject(updatedFeedback);
}
});
});
it('should update a feedback with special character', async () => {
const dto: CreateFeedbackDto = {
channelId: channel.id,
data: {},
};
let availableFieldKey = '';
fields
.filter(
({ key }) =>
key !== 'id' &&
key !== 'issues' &&
key !== 'createdAt' &&
key !== 'updatedAt',
)
.forEach(({ key, format, options }) => {
dto.data[key] = getRandomValue(format, options);
availableFieldKey = key;
});
const feedback = await feedbackService.create(dto);
return request(app.getHttpServer() as Server)
.put(
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/${feedback.id}`,
)
.set('Authorization', `Bearer ${accessToken}`)
.send({
[availableFieldKey]: '?',
})
.expect(200)
.then(async () => {
if (configService.get('opensearch.use')) {
const esResult = await osService.get({
id: feedback.id.toString(),
index: channel.id.toString(),
});
['id', 'createdAt', 'updatedAt'].forEach(
(field) => delete esResult.body._source?.[field],
);
dto.data[availableFieldKey] = '?';
expect(dto.data).toMatchObject(esResult.body._source ?? {});
} else {
const updatedFeedback = await feedbackService.findById({
channelId: channel.id,
feedbackId: feedback.id,
});
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
(field) => delete updatedFeedback[field],
);
dto.data[availableFieldKey] = '?';
expect(dto.data).toMatchObject(updatedFeedback);
}
});
});
});
describe('old feedback deletion test', () => {
it('should create feedbacks and delete feedbacks within specific date range', async () => {
const dto: Record<string, string | number | string[] | number[]> = {};
fields
.filter(
({ key }) =>
key !== 'id' &&
key !== 'issues' &&
key !== 'createdAt' &&
key !== 'updatedAt',
)
.forEach(({ key, format, options }) => {
dto[key] = getRandomValue(format, options);
});
dto.createdAt = DateTime.now().minus({ month: 7 }).toFormat('yyyy-MM-dd');
await request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
.send(dto)
.expect(201);
dto.createdAt = DateTime.now().minus({ days: 1 }).toFormat('yyyy-MM-dd');
await request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
.send(dto)
.expect(201);
await tenantService.deleteOldFeedbacks();
const findFeedbackDto: FindFeedbacksByChannelIdRequestDtoV2 = {
defaultQueries: [
{
key: 'createdAt',
value: {
gte: DateTime.now().minus({ years: 1 }).toFormat('yyyy-MM-dd'),
lt: DateTime.now().toFormat('yyyy-MM-dd'),
},
condition: QueryV2ConditionsEnum.BETWEEN,
},
],
operator: 'AND',
limit: 10,
page: 1,
};
return request(app.getHttpServer() as Server)
.post(
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/search`,
)
.set('Authorization', `Bearer ${accessToken}`)
.send(findFeedbackDto)
.expect(201)
.then(({ body }: { body: FindFeedbacksByChannelIdResponseDto }) => {
expect(body.meta.itemCount).toBe(1);
});
});
});
afterAll(async () => {
await clearEntities([tenantRepo, projectRepo, channelRepo, fieldRepo]);
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await delay(500);
await app.close();
});
});
@@ -0,0 +1,283 @@
/**
* 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 { IssueStatusEnum } from '@/common/enums';
import { OpensearchRepository } from '@/common/repositories';
import { AuthService } from '@/domains/admin/auth/auth.service';
import { FindIssuesByProjectIdRequestDto } from '@/domains/admin/project/issue/dtos/requests';
import type {
FindIssueByIdResponseDto,
FindIssuesByProjectIdResponseDto,
} from '@/domains/admin/project/issue/dtos/responses';
import type { CountIssuesByIdResponseDto } from '@/domains/admin/project/project/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('IssueController (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/issues (POST)', () => {
it('should create an issue', async () => {
return request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/issues`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ name: 'TestIssue' })
.expect(201);
});
});
describe('/admin/projects/:projectId/issues/:issueId (GET)', () => {
it('should get an issue', async () => {
return request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/issues/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.then(({ body }: { body: FindIssueByIdResponseDto }) => {
expect(body.name).toBe('TestIssue');
});
});
});
describe('/admin/projects/:projectId/issue-count (GET)', () => {
it('should return correct issue count', async () => {
return request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/issue-count`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.then(({ body }: { body: CountIssuesByIdResponseDto }) => {
expect(body.total).toBe(1);
});
});
});
describe('/admin/projects/:projectId/issues/search (POST)', () => {
it('should return all searched issues', async () => {
await request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/issues`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ name: 'TestIssue2' })
.expect(201);
const searchDto = new FindIssuesByProjectIdRequestDto();
searchDto.query = {
searchText: 'TestIssue',
};
searchDto.page = 1;
searchDto.limit = 10;
return request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/issues/search`)
.set('Authorization', `Bearer ${accessToken}`)
.send(searchDto)
.expect(201)
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
expect(body).toBeDefined();
expect(body).toHaveProperty('items');
expect(body.items.length).toBe(2);
});
});
});
describe('/admin/projects/:projectId/issues/:issueId (PUT)', () => {
it('should update an issue', async () => {
await request(app.getHttpServer() as Server)
.put(`/admin/projects/${project.id}/issues/1`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'TestIssue',
description: 'TestIssueUpdated',
status: IssueStatusEnum.IN_PROGRESS,
})
.expect(200);
return request(app.getHttpServer() as Server)
.get(`/admin/projects/${project.id}/issues/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.then(({ body }: { body: FindIssueByIdResponseDto }) => {
expect(body.description).toBe('TestIssueUpdated');
expect(body.status).toBe(IssueStatusEnum.IN_PROGRESS);
});
});
});
describe('/admin/projects/:projectId/issues/:issueId (DELETE)', () => {
it('should delete an issue', async () => {
await request(app.getHttpServer() as Server)
.delete(`/admin/projects/${project.id}/issues/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const searchDto = new FindIssuesByProjectIdRequestDto();
searchDto.query = {
searchText: 'TestIssue',
};
searchDto.page = 1;
searchDto.limit = 10;
return request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/issues/search`)
.set('Authorization', `Bearer ${accessToken}`)
.send(searchDto)
.expect(201)
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
expect(body).toBeDefined();
expect(body).toHaveProperty('items');
expect(body.items.length).toBe(1);
});
});
});
describe('/admin/projects/:projectId/issues (DELETE)', () => {
it('should delete many issues', async () => {
await request(app.getHttpServer() as Server)
.delete(`/admin/projects/${project.id}/issues`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ issueIds: [2] })
.expect(200);
const searchDto = new FindIssuesByProjectIdRequestDto();
searchDto.query = {
searchText: 'TestIssue',
};
searchDto.page = 1;
searchDto.limit = 10;
return request(app.getHttpServer() as Server)
.post(`/admin/projects/${project.id}/issues/search`)
.set('Authorization', `Bearer ${accessToken}`)
.send(searchDto)
.expect(201)
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
expect(body).toBeDefined();
expect(body).toHaveProperty('items');
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 () => {
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await delay(500);
await app.close();
});
});
@@ -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();
});
});
@@ -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, getRepositoryToken } from '@nestjs/typeorm';
import request from 'supertest';
import type { DataSource, Repository } 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 { ChannelService } from '@/domains/admin/channel/channel/channel.service';
import { FieldEntity } from '@/domains/admin/channel/field/field.entity';
import { FeedbackService } from '@/domains/admin/feedback/feedback.service';
import {
CreateProjectRequestDto,
FindProjectsRequestDto,
UpdateProjectRequestDto,
} from '@/domains/admin/project/project/dtos/requests';
import type {
CountFeedbacksByIdResponseDto,
FindProjectByIdResponseDto,
FindProjectsResponseDto,
} from '@/domains/admin/project/project/dtos/responses';
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,
createChannel,
createFeedback,
signInTestUser,
} from '@/test-utils/util-functions';
describe('ProjectController (integration)', () => {
let app: INestApplication;
let dataSource: DataSource;
let authService: AuthService;
let tenantService: TenantService;
let projectService: ProjectService;
let channelService: ChannelService;
let feedbackService: FeedbackService;
let configService: ConfigService;
let fieldRepo: Repository<FieldEntity>;
let opensearchRepository: OpensearchRepository;
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);
channelService = module.get(ChannelService);
feedbackService = module.get(FeedbackService);
configService = module.get(ConfigService);
opensearchRepository = module.get(OpensearchRepository);
fieldRepo = module.get(getRepositoryToken(FieldEntity));
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);
const { jwt } = await signInTestUser(dataSource, authService);
accessToken = jwt.accessToken;
});
describe('/admin/projects (POST)', () => {
it('should create a project', async () => {
const dto = new CreateProjectRequestDto();
dto.name = 'TestProject';
return request(app.getHttpServer() as Server)
.post(`/admin/projects`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto)
.expect(201);
});
});
describe('/admin/projects (GET)', () => {
it('should find projects', async () => {
const dto = new FindProjectsRequestDto();
dto.limit = 10;
dto.page = 1;
return request(app.getHttpServer() as Server)
.get(`/admin/projects`)
.set('Authorization', `Bearer ${accessToken}`)
.query(dto)
.expect(200)
.then(({ body }: { body: FindProjectsResponseDto }) => {
expect(body.items.length).toEqual(1);
expect(body.items[0].name).toEqual('TestProject');
});
});
});
describe('/admin/projects/:projectId (GET)', () => {
it('should find a project by id', async () => {
const dto = new FindProjectsRequestDto();
dto.limit = 10;
dto.page = 1;
return request(app.getHttpServer() as Server)
.get(`/admin/projects/1`)
.set('Authorization', `Bearer ${accessToken}`)
.query(dto)
.expect(200)
.then(({ body }: { body: FindProjectByIdResponseDto }) => {
expect(body.name).toEqual('TestProject');
});
});
});
describe('/admin/projects/:projectId/feedback-count (GET)', () => {
it('should count feedbacks by project id', async () => {
const project = await projectService.findById({ projectId: 1 });
const channel = await createChannel(channelService, project);
const fields = await fieldRepo.find({
where: { channel: { id: channel.id } },
relations: { options: true },
});
await createFeedback(fields, channel.id, feedbackService);
return request(app.getHttpServer() as Server)
.get(`/admin/projects/1/feedback-count`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.then(({ body }: { body: CountFeedbacksByIdResponseDto }) => {
expect(body.total).toEqual(1);
});
});
});
describe('/admin/projects/:projectId (PUT)', () => {
it('should update a project', async () => {
const dto = new UpdateProjectRequestDto();
dto.name = 'UpdatedTestProject';
await request(app.getHttpServer() as Server)
.put(`/admin/projects/1`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto)
.expect(200);
const findDto = new FindProjectsRequestDto();
findDto.limit = 10;
findDto.page = 1;
return request(app.getHttpServer() as Server)
.get(`/admin/projects`)
.set('Authorization', `Bearer ${accessToken}`)
.query(findDto)
.expect(200)
.then(({ body }: { body: FindProjectsResponseDto }) => {
expect(body.items.length).toEqual(1);
expect(body.items[0].name).toEqual('UpdatedTestProject');
});
});
});
describe('/admin/projects/:projectId (DELETE)', () => {
it('should delete a project', async () => {
await request(app.getHttpServer() as Server)
.delete(`/admin/projects/1`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const findDto = new FindProjectsRequestDto();
findDto.limit = 10;
findDto.page = 1;
return request(app.getHttpServer() as Server)
.get(`/admin/projects`)
.set('Authorization', `Bearer ${accessToken}`)
.query(findDto)
.expect(200)
.then(({ body }: { body: FindProjectsResponseDto }) => {
expect(body.items.length).toEqual(0);
});
});
});
afterAll(async () => {
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await delay(500);
await app.close();
});
});
@@ -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();
});
});
@@ -0,0 +1,199 @@
/**
* 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 type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import request from 'supertest';
import type { DataSource, Repository } from 'typeorm';
import { initializeTransactionalContext } from 'typeorm-transactional';
import { AppModule } from '@/app.module';
import { AuthService } from '@/domains/admin/auth/auth.service';
import {
SetupTenantRequestDto,
UpdateTenantRequestDto,
} from '@/domains/admin/tenant/dtos/requests';
import type { GetTenantResponseDto } from '@/domains/admin/tenant/dtos/responses';
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
import {
clearAllEntities,
clearEntities,
signInTestUser,
} from '@/test-utils/util-functions';
import { HttpStatusCode } from '@/types/http-status';
describe('TenantController (integration)', () => {
let module: TestingModule;
let app: INestApplication;
let dataSource: DataSource;
let tenantRepo: Repository<TenantEntity>;
let userRepo: Repository<UserEntity>;
let authService: AuthService;
beforeAll(async () => {
initializeTransactionalContext();
module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
dataSource = module.get(getDataSourceToken());
tenantRepo = dataSource.getRepository(TenantEntity);
userRepo = dataSource.getRepository(UserEntity);
authService = module.get(AuthService);
app = module.createNestApplication();
await app.init();
});
beforeEach(async () => {
await clearAllEntities(module);
});
describe('/admin/tenants (POST)', () => {
it('should create a tenant', async () => {
const dto = new SetupTenantRequestDto();
dto.siteName = faker.string.sample();
dto.password = '12345678';
return await request(app.getHttpServer() as Server)
.post('/admin/tenants')
.send(dto)
.expect(201)
.then(async () => {
const tenants = await tenantRepo.find();
expect(tenants).toHaveLength(1);
const [tenant] = tenants;
for (const key in dto) {
if (['email', 'password'].includes(key)) continue;
const value = dto[key] as string;
expect(tenant[key]).toEqual(value);
}
});
});
it('should return bad request since tenant is already exists', async () => {
await tenantRepo.save({
siteName: faker.string.sample(),
allowDomains: [],
});
const dto = new SetupTenantRequestDto();
dto.siteName = faker.string.sample();
dto.password = '12345678';
return request(app.getHttpServer() as Server)
.post('/admin/tenants')
.send(dto)
.expect(400);
});
afterAll(async () => {
await clearEntities([tenantRepo]);
});
});
describe('/admin/tenants (PUT)', () => {
let tenant: TenantEntity;
let accessToken: string;
beforeEach(async () => {
tenant = await tenantRepo.save({
siteName: faker.string.sample(),
allowDomains: [],
});
const { jwt } = await signInTestUser(dataSource, authService);
accessToken = jwt.accessToken;
});
it('should update a tenant', async () => {
const dto = new UpdateTenantRequestDto();
dto.siteName = faker.string.sample();
dto.allowDomains = [];
return await request(app.getHttpServer() as Server)
.put('/admin/tenants')
.set('Authorization', `Bearer ${accessToken}`)
.send(dto)
.expect(204)
.then(async () => {
const updatedTenant = await tenantRepo.findOne({
where: { id: tenant.id },
});
expect(updatedTenant?.siteName).toEqual(dto.siteName);
expect(updatedTenant?.allowDomains).toEqual(dto.allowDomains);
});
});
it('should fail to find a tenant', async () => {
await clearEntities([tenantRepo]);
const dto = new UpdateTenantRequestDto();
dto.siteName = faker.string.sample();
dto.allowDomains = [];
return request(app.getHttpServer() as Server)
.put('/admin/tenants')
.set('Authorization', `Bearer ${accessToken}`)
.send(dto)
.expect(404);
});
it('should reject the request when unauthorized', async () => {
const dto = new UpdateTenantRequestDto();
dto.siteName = faker.string.sample();
dto.allowDomains = [];
return await request(app.getHttpServer() as Server)
.put('/admin/tenants')
.send(dto)
.expect(HttpStatusCode.UNAUTHORIZED);
});
});
describe('/admin/tenants (GET)', () => {
const dto = new SetupTenantRequestDto();
beforeEach(async () => {
await clearEntities([tenantRepo, userRepo]);
dto.siteName = faker.string.sample();
dto.password = '12345678';
await request(app.getHttpServer() as Server)
.post('/admin/tenants')
.send(dto);
});
it('should find a tenant', async () => {
await request(app.getHttpServer() as Server)
.get('/admin/tenants')
.expect(200)
.expect(({ body }) => {
expect(dto.siteName).toEqual((body as GetTenantResponseDto).siteName);
});
});
});
afterAll(async () => {
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await delay(500);
await app.close();
});
});
@@ -0,0 +1,246 @@
/**
* 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 { ValidationPipe } from '@nestjs/common';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import { DateTime } from 'luxon';
import request from 'supertest';
import type { DataSource, Repository } from 'typeorm';
import { AppModule } from '@/app.module';
import { AuthService } from '@/domains/admin/auth/auth.service';
import { RoleEntity } from '@/domains/admin/project/role/role.entity';
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
import { TenantService } from '@/domains/admin/tenant/tenant.service';
import type { UserDto } from '@/domains/admin/user/dtos';
import type { GetAllUserResponseDto } from '@/domains/admin/user/dtos/responses/get-all-user-response.dto';
import { UserStateEnum } from '@/domains/admin/user/entities/enums';
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
import {
clearEntities,
createTenant,
getRandomEnumValue,
signInTestUser,
} from '@/test-utils/util-functions';
import { HttpStatusCode } from '@/types/http-status';
describe('UserController (integration)', () => {
let app: INestApplication;
let dataSource: DataSource;
let userRepo: Repository<UserEntity>;
let roleRepo: Repository<RoleEntity>;
let tenantRepo: Repository<TenantEntity>;
let tenantService: TenantService;
let authService: AuthService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true }),
);
await app.init();
dataSource = module.get(getDataSourceToken());
userRepo = dataSource.getRepository(UserEntity);
roleRepo = dataSource.getRepository(RoleEntity);
tenantRepo = dataSource.getRepository(TenantEntity);
authService = module.get(AuthService);
tenantService = module.get(TenantService);
await clearEntities([tenantRepo, userRepo, roleRepo]);
await createTenant(tenantService);
});
afterAll(async () => {
await dataSource.destroy();
await app.close();
});
let total: number;
let userEntities: UserEntity[];
let accessToken: string;
let ownerUser: UserEntity;
beforeEach(async () => {
await clearEntities([userRepo, roleRepo]);
const length = faker.number.int({ min: 3, max: 8 });
userEntities = (
await userRepo.save(
Array.from({ length: length }).map(() => ({
email: faker.internet.email(),
state: getRandomEnumValue(UserStateEnum),
hashPassword: faker.internet.password(),
})),
)
).sort((a, b) =>
DateTime.fromJSDate(b.createdAt)
.diff(DateTime.fromJSDate(a.createdAt))
.as('milliseconds'),
);
const { jwt, user } = await signInTestUser(dataSource, authService);
accessToken = jwt.accessToken;
ownerUser = user;
total = length + 1;
});
describe('/admin/users (GET)', () => {
it('should return all users', async () => {
const expectUsers = userEntities
.concat(ownerUser)
.sort((a, b) =>
DateTime.fromJSDate(a.createdAt)
.diff(DateTime.fromJSDate(b.createdAt))
.as('milliseconds'),
)
.map(({ id, email }) => ({
id,
email,
}))
.slice(0, 10);
return request(app.getHttpServer() as Server)
.get('/admin/users')
.set('Authorization', `Bearer ${accessToken}`)
.expect(HttpStatusCode.OK)
.expect(({ body }) => {
expect(body).toHaveProperty('items');
expect(body).toHaveProperty('meta');
const { items, meta } = body as GetAllUserResponseDto;
[
'name',
'department',
'type',
'members',
'createdAt',
'signUpMethod',
].forEach((field) => items.forEach((item) => delete item[field]));
expect(items).toEqual(expectUsers);
expect(meta.totalItems).toEqual(total);
});
});
it('should return unauthorized status code', async () => {
return request(app.getHttpServer() as Server)
.get('/admin/users')
.expect(HttpStatusCode.UNAUTHORIZED);
});
});
describe('/admin/users (DELETE)', () => {
it('should return empty result', async () => {
const ids = faker.helpers.arrayElements(userEntities).map((v) => v.id);
await request(app.getHttpServer() as Server)
.delete(`/admin/users`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ ids })
.expect(HttpStatusCode.OK)
.then(async () => {
for (const id of ids) {
const result = await userRepo.findOneBy({ id });
expect(result).toBeNull();
}
});
});
it('should return unauthorized status code', async () => {
await request(app.getHttpServer() as Server)
.delete(`/admin/users`)
.expect(HttpStatusCode.UNAUTHORIZED);
});
});
describe('/admin/users/:id (GET)', () => {
it('check signed-in user', async () => {
await request(app.getHttpServer() as Server)
.get(`/admin/users/${ownerUser.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect(({ body }) => {
expect((body as UserDto).id).toEqual(ownerUser.id);
expect((body as UserDto).email).toEqual(ownerUser.email);
});
});
it('should return unauthorized status code', async () => {
await request(app.getHttpServer() as Server)
.get(`/admin/users/${ownerUser.id}`)
.expect(HttpStatusCode.UNAUTHORIZED);
});
});
describe('/admin/users/:id (DELETE)', () => {
it('should return empty result', async () => {
return request(app.getHttpServer() as Server)
.delete(`/admin/users/${ownerUser.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(HttpStatusCode.OK)
.then(async () => {
const result = await userRepo.findOneBy({ id: ownerUser.id });
expect(result).toBeNull();
});
});
it('should return unauthorized status code', async () => {
return request(app.getHttpServer() as Server)
.delete(`/admin/users/${faker.number.int()}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(HttpStatusCode.UNAUTHORIZED);
});
it('should return unauthorized status code', async () => {
return request(app.getHttpServer() as Server)
.delete(`/admin/users/${ownerUser.id}`)
.expect(HttpStatusCode.UNAUTHORIZED);
});
});
describe('/admin/users/:id/roles (GET)', () => {
it('should return OK', async () => {
await request(app.getHttpServer() as Server)
.get(`/admin/users/${ownerUser.id}/roles`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(HttpStatusCode.OK);
});
});
describe('/admin/users/:id/roles (PUT)', () => {
it('should return unauthorized status code', async () => {
const role = await roleRepo.save({
name: faker.string.sample(),
permissions: [],
});
await request(app.getHttpServer() as Server)
.put(`/admin/users/${ownerUser.id}`)
.send({ roleId: role.id })
.expect(HttpStatusCode.UNAUTHORIZED);
});
});
});
@@ -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();
});
});