diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 000000000..6ca274493 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,76 @@ +name: Integration Tests + +on: + pull_request: + branches: [dev, main] + +jobs: + integration-test: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0.39 + env: + MYSQL_ROOT_PASSWORD: userfeedback + MYSQL_DATABASE: e2e + MYSQL_USER: userfeedback + MYSQL_PASSWORD: userfeedback + TZ: UTC + ports: + - 13307:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + smtp: + image: rnwood/smtp4dev:v3 + ports: + - 5080:80 + - 25:25 + - 143:143 + + opensearch: + image: opensearchproject/opensearch:2.17.1 + env: + discovery.type: single-node + bootstrap.memory_lock: "true" + plugins.security.disabled: "true" + OPENSEARCH_INITIAL_ADMIN_PASSWORD: "UserFeedback123!@#" + options: >- + --health-cmd="curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"'" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + ports: + - 9200:9200 + + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Setup integration test (with opensearch) + run: | + npx corepack enable + pnpm install --frozen-lockfile + pnpm build + echo "BASE_URL=http://localhost:3000" >> ./apps/api/.env + echo "JWT_SECRET=DEV" >> ./apps/api/.env + echo "OPENSEARCH_USE=true" >> ./apps/api/.env + echo "OPENSEARCH_NODE=http://localhost:9200" >> ./apps/api/.env + echo "OPENSEARCH_USERNAME=''" >> ./apps/api/.env + echo "OPENSEARCH_PASSWORD=''" >> ./apps/api/.env + + - name: Run integration tests (with opensearch) + run: | + npm run test:integration + + - name: Setup integration test (without opensearch) + run: | + echo "OPENSEARCH_USE=false" >> ./apps/api/.env + + - name: Run integration tests (without opensearch) + run: | + npm run test:integration diff --git a/.nvmrc b/.nvmrc index ec09f38d1..c13022261 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.17.0 \ No newline at end of file +20.18.0 \ No newline at end of file diff --git a/apps/api/integration-test/database-utils.ts b/apps/api/integration-test/database-utils.ts new file mode 100644 index 000000000..3b1c0b06f --- /dev/null +++ b/apps/api/integration-test/database-utils.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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', + }); +} diff --git a/apps/api/integration-test/global.setup.ts b/apps/api/integration-test/global.setup.ts new file mode 100644 index 000000000..3b37cb03c --- /dev/null +++ b/apps/api/integration-test/global.setup.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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.MASTER_API_KEY = 'master-api-key'; + +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(); +} + +module.exports = async () => { + await createTestDatabase(); + await runMigrations(); +}; diff --git a/apps/api/integration-test/global.teardown.ts b/apps/api/integration-test/global.teardown.ts new file mode 100644 index 000000000..07c73e2bd --- /dev/null +++ b/apps/api/integration-test/global.teardown.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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(); +} + +module.exports = async () => { + await dropTestDatabase(); +}; diff --git a/apps/api/integration-test/jest-integration.json b/apps/api/integration-test/jest-integration.json new file mode 100644 index 000000000..1ec4f3e05 --- /dev/null +++ b/apps/api/integration-test/jest-integration.json @@ -0,0 +1,18 @@ +{ + "displayName": "api-integration", + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "moduleNameMapper": { + "^@/(.*)$": ["/../src/$1"] + }, + "testRegex": ".integration-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "setupFilesAfterEnv": [ + "/../integration-test/jest-integration.setup.ts" + ], + "globalSetup": "/../integration-test/global.setup.ts", + "globalTeardown": "/../integration-test/global.teardown.ts" +} diff --git a/apps/api/integration-test/jest-integration.setup.ts b/apps/api/integration-test/jest-integration.setup.ts new file mode 100644 index 000000000..a6f539883 --- /dev/null +++ b/apps/api/integration-test/jest-integration.setup.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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(), + }; +}); diff --git a/apps/api/integration-test/test-specs/channel.integration-spec.ts b/apps/api/integration-test/test-specs/channel.integration-spec.ts new file mode 100644 index 000000000..4581217f0 --- /dev/null +++ b/apps/api/integration-test/test-specs/channel.integration-spec.ts @@ -0,0 +1,254 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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(); + 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 fields with special character', async () => { + const dto = new UpdateChannelFieldsRequestDto(); + const fieldDto = new UpdateChannelRequestFieldDto(); + fieldDto.id = 5; + fieldDto.format = FieldFormatEnum.text; + fieldDto.key = 'testField'; + fieldDto.name = '!'; + 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); + }); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/feedback.integration-spec.ts b/apps/api/integration-test/test-specs/feedback.integration-spec.ts new file mode 100644 index 000000000..45f1d6f10 --- /dev/null +++ b/apps/api/integration-test/test-specs/feedback.integration-spec.ts @@ -0,0 +1,355 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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 request from 'supertest'; +import type { DataSource, Repository } from 'typeorm'; +import { initializeTransactionalContext } from 'typeorm-transactional'; + +import { AppModule } from '@/app.module'; +import { FieldFormatEnum } 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 { FindFeedbacksByChannelIdRequestDto } from '@/domains/admin/feedback/dtos/requests'; +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'; + +interface OpenSearchResponse { + _source: Record; + total: { value: number }; +} + +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; + let projectRepo: Repository; + let channelRepo: Repository; + let fieldRepo: Repository; + 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('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 = {}; + 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 & { 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: {}, + }; + fields + .filter( + ({ key }) => + key !== 'id' && + key !== 'issues' && + key !== 'createdAt' && + key !== 'updatedAt', + ) + .forEach(({ key, format, options }) => { + dto.data[key] = getRandomValue(format, options); + }); + + await feedbackService.create(dto); + + const keywordField = fields.find( + ({ format }) => format === FieldFormatEnum.keyword, + ); + if (!keywordField) return; + + const findFeedbackDto: FindFeedbacksByChannelIdRequestDto = { + query: { + searchText: dto.data[keywordField.key] as string, + }, + 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).toBeGreaterThan(0); + }); + }); + }); + + 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); + } + }); + }); + }); + + afterAll(async () => { + await clearEntities([tenantRepo, projectRepo, channelRepo, fieldRepo]); + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/issue.integration-spec.ts b/apps/api/integration-test/test-specs/issue.integration-spec.ts new file mode 100644 index 000000000..1c440016b --- /dev/null +++ b/apps/api/integration-test/test-specs/issue.integration-spec.ts @@ -0,0 +1,247 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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(); + 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/:issueId (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); + }); + }); + }); + + afterAll(async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + await delay(500); + await app.close(); + }); +}); diff --git a/apps/api/integration-test/test-specs/project.integration-spec.ts b/apps/api/integration-test/test-specs/project.integration-spec.ts new file mode 100644 index 000000000..6084f0a39 --- /dev/null +++ b/apps/api/integration-test/test-specs/project.integration-spec.ts @@ -0,0 +1,232 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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; + + 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(); + 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(); + }); +}); diff --git a/apps/api/integration-test/test-specs/tenant.integration-spec.ts b/apps/api/integration-test/test-specs/tenant.integration-spec.ts new file mode 100644 index 000000000..d3796426d --- /dev/null +++ b/apps/api/integration-test/test-specs/tenant.integration-spec.ts @@ -0,0 +1,204 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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, 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; + let userRepo: Repository; + + 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(); + + 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) { + 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(), + isPrivate: faker.datatype.boolean(), + isRestrictDomain: faker.datatype.boolean(), + allowDomains: [], + }); + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + + return request(app.getHttpServer() as Server) + .post('/admin/tenants') + .send(dto) + .expect(400); + }); + + afterAll(async () => { + await tenantRepo.delete({}); + }); + }); + + describe('/admin/tenants (PUT)', () => { + let tenant: TenantEntity; + let accessToken: string; + beforeEach(async () => { + tenant = await tenantRepo.save({ + siteName: faker.string.sample(), + isPrivate: faker.datatype.boolean(), + isRestrictDomain: faker.datatype.boolean(), + 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.isPrivate = faker.datatype.boolean(); + dto.isRestrictDomain = faker.datatype.boolean(); + 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?.isPrivate).toEqual(dto.isPrivate); + expect(updatedTenant?.isRestrictDomain).toEqual(dto.isRestrictDomain); + expect(updatedTenant?.allowDomains).toEqual(dto.allowDomains); + }); + }); + it('should fail to find a tenant', async () => { + await tenantRepo.delete({}); + + const dto = new UpdateTenantRequestDto(); + + dto.siteName = faker.string.sample(); + dto.isPrivate = faker.datatype.boolean(); + dto.isRestrictDomain = faker.datatype.boolean(); + 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.isPrivate = faker.datatype.boolean(); + dto.isRestrictDomain = faker.datatype.boolean(); + 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 tenantRepo.delete({}); + await userRepo.delete({}); + dto.siteName = faker.string.sample(); + + 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(); + }); +}); diff --git a/apps/api/integration-test/test-specs/user.integration-spec.ts b/apps/api/integration-test/test-specs/user.integration-spec.ts new file mode 100644 index 000000000..359730a45 --- /dev/null +++ b/apps/api/integration-test/test-specs/user.integration-spec.ts @@ -0,0 +1,234 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE 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 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, + 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; + let roleRepo: Repository; + + 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); + authService = module.get(AuthService); + }); + + 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); + }); + }); +}); diff --git a/apps/api/package.json b/apps/api/package.json index 5d81b1421..b400ce79b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json --runInBand --detectOpenHandles", + "test:integration": "jest --config ./integration-test/jest-integration.json --runInBand --detectOpenHandles", "test:watch": "jest --watch --detectOpenHandles", "typecheck": "tsc --noEmit", "typeorm": "ts-node --project ./tsconfig.json -r tsconfig-paths/register ./node_modules/typeorm/cli -d src/configs/modules/typeorm-config/typeorm-config.datasource.ts" @@ -94,7 +95,7 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/luxon": "^3.4.2", - "@types/node": "20.16.2", + "@types/node": "20.16.11", "@types/nodemailer": "^6.4.15", "@types/passport-jwt": "*", "@types/supertest": "^6.0.2", @@ -104,11 +105,11 @@ "@ufb/prettier-config": "workspace:*", "@ufb/tsconfig": "workspace:*", "eslint": "catalog:", - "prettier": "catalog:", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", "mockdate": "^3.0.5", + "prettier": "catalog:", "supertest": "^7.0.0", "ts-jest": "^29.1.2", "ts-loader": "^9.5.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8a80db030..b166df67e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -106,6 +106,10 @@ export const domainModules = [ ignore: (req: Request) => req.originalUrl === '/api/health', }, customLogLevel: (req, res, err) => { + if (process.env.NODE_ENV === 'test') { + return 'silent'; + } + if (res.statusCode === 401) { return 'silent'; } diff --git a/apps/api/src/common/repositories/opensearch.repository.ts b/apps/api/src/common/repositories/opensearch.repository.ts index e705786ff..f7e9a7abc 100644 --- a/apps/api/src/common/repositories/opensearch.repository.ts +++ b/apps/api/src/common/repositories/opensearch.repository.ts @@ -214,6 +214,10 @@ export class OpensearchRepository { await this.opensearchClient.indices.delete({ index: 'channel_' + index }); } + async deleteAllIndexes() { + await this.opensearchClient.indices.delete({ index: '_all' }); + } + async getTotal(index: string, query: object): Promise { const { body } = await this.opensearchClient.count({ index, diff --git a/apps/api/src/domains/admin/channel/channel/dtos/requests/index.ts b/apps/api/src/domains/admin/channel/channel/dtos/requests/index.ts index 55fd71016..8d52d8e49 100644 --- a/apps/api/src/domains/admin/channel/channel/dtos/requests/index.ts +++ b/apps/api/src/domains/admin/channel/channel/dtos/requests/index.ts @@ -15,7 +15,9 @@ */ export { CreateChannelRequestDto } from './create-channel-request.dto'; export { FindChannelsByProjectIdRequestDto } from './find-channels-by-project-id-request.dto'; +export { CreateChannelRequestFieldDto } from './create-channel-request.dto'; export { ImageConfigRequestDto } from './image-config-request.dto'; export { UpdateChannelRequestDto } from './update-channel-request.dto'; export { UpdateChannelFieldsRequestDto } from './update-channel-fields-request.dto'; +export { UpdateChannelRequestFieldDto } from './update-channel-fields-request.dto'; export { ImageUploadUrlTestRequestDto } from './image-upload-url-test-request.dto'; diff --git a/apps/api/src/domains/admin/channel/channel/dtos/requests/update-channel-fields-request.dto.ts b/apps/api/src/domains/admin/channel/channel/dtos/requests/update-channel-fields-request.dto.ts index 0b17c73c0..08c4bf232 100644 --- a/apps/api/src/domains/admin/channel/channel/dtos/requests/update-channel-fields-request.dto.ts +++ b/apps/api/src/domains/admin/channel/channel/dtos/requests/update-channel-fields-request.dto.ts @@ -19,7 +19,7 @@ import { IsNumber, IsOptional, ValidateNested } from 'class-validator'; import { CreateChannelRequestFieldDto } from './create-channel-request.dto'; -class UpdateChannelRequestFieldDto extends CreateChannelRequestFieldDto { +export class UpdateChannelRequestFieldDto extends CreateChannelRequestFieldDto { @ApiProperty({ required: false }) @IsNumber() @IsOptional() diff --git a/apps/api/src/domains/admin/channel/field/field.mysql.service.ts b/apps/api/src/domains/admin/channel/field/field.mysql.service.ts index 02cff31bb..c574747aa 100644 --- a/apps/api/src/domains/admin/channel/field/field.mysql.service.ts +++ b/apps/api/src/domains/admin/channel/field/field.mysql.service.ts @@ -50,6 +50,11 @@ export class FieldMySQLService { if (!validateUnique(fields, 'key')) { throw new FieldKeyDuplicatedException(); } + fields.forEach(({ name }) => { + if (/^[a-z0-9_-]+$/i.test(name) === false) { + throw new BadRequestException('field name should be alphanumeric'); + } + }); fields.forEach(({ format, options }) => { if (!this.isValidField(format, options ?? [])) { throw new BadRequestException('only select format field has options'); diff --git a/apps/api/src/domains/admin/feedback/feedback.mysql.service.ts b/apps/api/src/domains/admin/feedback/feedback.mysql.service.ts index 064f4dc48..fc711c610 100644 --- a/apps/api/src/domains/admin/feedback/feedback.mysql.service.ts +++ b/apps/api/src/domains/admin/feedback/feedback.mysql.service.ts @@ -125,24 +125,24 @@ export class FeedbackMySQLService { if (stringFields[i].format === FieldFormatEnum.keyword) { if (i === 0) { qb.where( - `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].id}"') = :value`, + `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].key}"') = :value`, { value }, ); } else { qb.orWhere( - `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].id}"') = :value`, + `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].key}"') = :value`, { value }, ); } } else { if (i === 0) { qb.where( - `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].id}"') like :likeValue`, + `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].key}"') like :likeValue`, { likeValue: `%${value as string | number}%` }, ); } else { qb.orWhere( - `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].id}"') like :likeValue`, + `JSON_EXTRACT(feedbacks.data, '$."${stringFields[i].key}"') like :likeValue`, { likeValue: `%${value as string | number}%` }, ); } @@ -292,12 +292,13 @@ export class FeedbackMySQLService { } let query = `JSON_SET(IFNULL(feedbacks.data,'{}'), `; for (const [index, fieldKey] of Object.entries(Object.keys(data))) { - query += `'$."${fieldKey}"', ${ + query += `'$.${fieldKey}', + ${ Array.isArray(data[fieldKey]) ? data[fieldKey].length === 0 ? 'JSON_ARRAY()' : 'JSON_ARRAY("' + data[fieldKey].join('","') + '")' - : '"' + data[fieldKey] + '"' + : `:${fieldKey}` }`; if (parseInt(index) + 1 !== Object.entries(data).length) { @@ -311,6 +312,7 @@ export class FeedbackMySQLService { updatedAt: () => `'${DateTime.utc().toFormat('yyyy-MM-dd HH:mm:ss')}'`, }) .where('id = :feedbackId', { feedbackId }) + .setParameters(data) .execute(); } diff --git a/apps/api/src/domains/admin/feedback/feedback.os.service.ts b/apps/api/src/domains/admin/feedback/feedback.os.service.ts index 84e25844b..4e9af7921 100644 --- a/apps/api/src/domains/admin/feedback/feedback.os.service.ts +++ b/apps/api/src/domains/admin/feedback/feedback.os.service.ts @@ -82,7 +82,7 @@ export class FeedbackOSService { if (fieldFormats.includes(field.format)) { prev.push({ match_phrase: { - [field.id]: query, + [field.key]: query, }, }); } diff --git a/apps/api/src/domains/admin/project/issue/issue.service.ts b/apps/api/src/domains/admin/project/issue/issue.service.ts index 4becf8f95..679162938 100644 --- a/apps/api/src/domains/admin/project/issue/issue.service.ts +++ b/apps/api/src/domains/admin/project/issue/issue.service.ts @@ -301,7 +301,17 @@ export class IssueService { }); } }); - this.schedulerRegistry.addCronJob(`feedback-count-by-issue-${id}`, job); + + const name = `feedback-count-by-issue-${id}`; + const cronJobs = this.schedulerRegistry.getCronJobs(); + if (cronJobs.has(name)) { + this.logger.warn( + `Cron job with name ${name} already exists. Skipping addition.`, + ); + return; + } + + this.schedulerRegistry.addCronJob(name, job); job.start(); } } diff --git a/apps/api/src/domains/admin/statistics/feedback-issue/feedback-issue-statistics.service.ts b/apps/api/src/domains/admin/statistics/feedback-issue/feedback-issue-statistics.service.ts index 15feaa83a..b2dc43889 100644 --- a/apps/api/src/domains/admin/statistics/feedback-issue/feedback-issue-statistics.service.ts +++ b/apps/api/src/domains/admin/statistics/feedback-issue/feedback-issue-statistics.service.ts @@ -141,10 +141,17 @@ export class FeedbackIssueStatisticsService { }); } }); - this.schedulerRegistry.addCronJob( - `feedback-issue-statistics-${projectId}`, - job, - ); + + const name = `feedback-issue-statistics-${projectId}`; + const cronJobs = this.schedulerRegistry.getCronJobs(); + if (cronJobs.has(name)) { + this.logger.warn( + `Cron job with name ${name} already exists. Skipping addition.`, + ); + return; + } + + this.schedulerRegistry.addCronJob(name, job); job.start(); this.logger.log(`feedback-issue-statistics-${projectId} cron job started`); diff --git a/apps/api/src/domains/admin/statistics/feedback/feedback-statistics.service.ts b/apps/api/src/domains/admin/statistics/feedback/feedback-statistics.service.ts index 549b1000a..f2e7726c2 100644 --- a/apps/api/src/domains/admin/statistics/feedback/feedback-statistics.service.ts +++ b/apps/api/src/domains/admin/statistics/feedback/feedback-statistics.service.ts @@ -185,7 +185,17 @@ export class FeedbackStatisticsService { }); } }); - this.schedulerRegistry.addCronJob(`feedback-statistics-${projectId}`, job); + + const name = `feedback-statistics-${projectId}`; + const cronJobs = this.schedulerRegistry.getCronJobs(); + if (cronJobs.has(name)) { + this.logger.warn( + `Cron job with name ${name} already exists. Skipping addition.`, + ); + return; + } + + this.schedulerRegistry.addCronJob(name, job); job.start(); this.logger.log(`feedback-statistics-${projectId} cron job started`); diff --git a/apps/api/src/domains/admin/statistics/issue/issue-statistics.service.ts b/apps/api/src/domains/admin/statistics/issue/issue-statistics.service.ts index b1e990bdd..670d37384 100644 --- a/apps/api/src/domains/admin/statistics/issue/issue-statistics.service.ts +++ b/apps/api/src/domains/admin/statistics/issue/issue-statistics.service.ts @@ -159,7 +159,17 @@ export class IssueStatisticsService { }); } }); - this.schedulerRegistry.addCronJob(`issue-statistics-${projectId}`, job); + + const name = `issue-statistics-${projectId}`; + const cronJobs = this.schedulerRegistry.getCronJobs(); + if (cronJobs.has(name)) { + this.logger.warn( + `Cron job with name ${name} already exists. Skipping addition.`, + ); + return; + } + + this.schedulerRegistry.addCronJob(name, job); job.start(); this.logger.log(`issue-statistics-${projectId} cron job started`); diff --git a/apps/api/src/domains/admin/tenant/tenant.controller.ts b/apps/api/src/domains/admin/tenant/tenant.controller.ts index 04fe9bc9a..58fc79dd9 100644 --- a/apps/api/src/domains/admin/tenant/tenant.controller.ts +++ b/apps/api/src/domains/admin/tenant/tenant.controller.ts @@ -22,9 +22,11 @@ import { ParseIntPipe, Post, Put, + UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/domains/admin/auth/guards'; import { SetupTenantRequestDto, UpdateTenantRequestDto } from './dtos/requests'; import { CountFeedbacksByTenantIdResponseDto, @@ -42,6 +44,7 @@ export class TenantController { await this.tenantService.create(body); } + @UseGuards(JwtAuthGuard) @Put() @HttpCode(204) async update(@Body() body: UpdateTenantRequestDto) { diff --git a/apps/api/src/test-utils/fixtures.ts b/apps/api/src/test-utils/fixtures.ts index 5458c56d4..592031967 100644 --- a/apps/api/src/test-utils/fixtures.ts +++ b/apps/api/src/test-utils/fixtures.ts @@ -78,8 +78,8 @@ export const createFieldDto = (input: Partial = {}) => { const property = input.property ?? getRandomEnumValue(FieldPropertyEnum); const status = input.status ?? getRandomEnumValue(FieldStatusEnum); return { - name: faker.string.alphanumeric(20), - key: faker.string.alphanumeric(20), + name: `_${faker.string.alphanumeric(20)}`, + key: `_${faker.string.alphanumeric(20)}`, description: faker.lorem.lines(2), format, property, @@ -115,7 +115,7 @@ export const getRandomValue = ( case FieldFormatEnum.keyword: return faker.string.sample(); case FieldFormatEnum.number: - return faker.number.int(); + return faker.number.int({ min: 1, max: 100 }); case FieldFormatEnum.select: return !options || options.length === 0 ? [] diff --git a/apps/api/src/test-utils/util-functions.ts b/apps/api/src/test-utils/util-functions.ts index 8bc5620e8..658468884 100644 --- a/apps/api/src/test-utils/util-functions.ts +++ b/apps/api/src/test-utils/util-functions.ts @@ -16,9 +16,16 @@ import { faker } from '@faker-js/faker'; import type { InjectionToken, Provider } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import type { TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import type { DataSource, Repository } from 'typeorm'; import { initializeTransactionalContext } from 'typeorm-transactional'; +import { + FieldFormatEnum, + FieldPropertyEnum, + FieldStatusEnum, +} from '@/common/enums'; import { appConfig } from '@/configs/app.config'; import { jwtConfig, jwtConfigSchema } from '@/configs/jwt.config'; import { @@ -27,9 +34,25 @@ import { } from '@/configs/opensearch.config'; import { smtpConfig, smtpConfigSchema } from '@/configs/smtp.config'; import type { AuthService } from '@/domains/admin/auth/auth.service'; +import { ChannelEntity } from '@/domains/admin/channel/channel/channel.entity'; +import type { 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 { FeedbackEntity } from '@/domains/admin/feedback/feedback.entity'; +import type { FeedbackService } from '@/domains/admin/feedback/feedback.service'; +import { ProjectEntity } from '@/domains/admin/project/project/project.entity'; +import type { ProjectService } from '@/domains/admin/project/project/project.service'; +import { RoleEntity } from '@/domains/admin/project/role/role.entity'; +import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests'; +import { TenantEntity } from '@/domains/admin/tenant/tenant.entity'; +import type { TenantService } from '@/domains/admin/tenant/tenant.service'; import { UserDto } from '@/domains/admin/user/dtos'; -import { UserStateEnum } from '@/domains/admin/user/entities/enums'; +import { + UserStateEnum, + UserTypeEnum, +} from '@/domains/admin/user/entities/enums'; import { UserEntity } from '@/domains/admin/user/entities/user.entity'; +import { createFieldDto, getRandomValue } from '@/test-utils/fixtures'; initializeTransactionalContext(); @@ -63,6 +86,103 @@ export const getRandomEnumValues = ( return faker.helpers.arrayElements(enumValues) as T[keyof T][]; }; +export const createTenant = async (tenantService: TenantService) => { + const dto = new SetupTenantRequestDto(); + dto.siteName = faker.string.sample(); + await tenantService.create(dto); +}; + +export const createProject = async (projectService: ProjectService) => { + return await projectService.create({ + name: faker.lorem.words(), + description: faker.lorem.lines(1), + timezone: { + countryCode: 'KR', + name: 'Asia/Seoul', + offset: '+09:00', + }, + }); +}; + +export const createChannel = async ( + channelService: ChannelService, + project: ProjectEntity, +) => { + return await channelService.create({ + projectId: project.id, + name: faker.string.alphanumeric(20), + description: faker.lorem.lines(1), + fields: Array.from({ + length: faker.number.int({ min: 1, max: 10 }), + }).map(() => + createFieldDto({ + format: FieldFormatEnum.keyword, + property: FieldPropertyEnum.EDITABLE, + status: FieldStatusEnum.ACTIVE, + }), + ), + imageConfig: null, + }); +}; + +export const createFeedback = async ( + fields: FieldEntity[], + channelId: number, + feedbackService: FeedbackService, +) => { + const dto: CreateFeedbackDto = { + channelId: channelId, + data: {}, + }; + fields + .filter( + ({ key }) => + key !== 'id' && + key !== 'issues' && + key !== 'createdAt' && + key !== 'updatedAt', + ) + .forEach(({ key, format, options }) => { + dto.data[key] = getRandomValue(format, options); + }); + + await feedbackService.create(dto); +}; + +export const clearAllEntities = async (module: TestingModule) => { + const userRepo: Repository = module.get( + getRepositoryToken(UserEntity), + ); + const roleRepo: Repository = module.get( + getRepositoryToken(RoleEntity), + ); + const tenantRepo: Repository = module.get( + getRepositoryToken(TenantEntity), + ); + const projectRepo: Repository = module.get( + getRepositoryToken(ProjectEntity), + ); + const channelRepo: Repository = module.get( + getRepositoryToken(ChannelEntity), + ); + const fieldRepo: Repository = module.get( + getRepositoryToken(FieldEntity), + ); + const feedbackRepo: Repository = module.get( + getRepositoryToken(FeedbackEntity), + ); + + await clearEntities([ + userRepo, + roleRepo, + tenantRepo, + projectRepo, + channelRepo, + fieldRepo, + feedbackRepo, + ]); +}; + export const clearEntities = async (repos: Repository[]) => { for (const repo of repos) { await repo.query('set foreign_key_checks = 0'); @@ -80,6 +200,7 @@ export const signInTestUser = async ( email: faker.internet.email(), state: UserStateEnum.Active, hashPassword: faker.internet.password(), + type: UserTypeEnum.SUPER, }); return { jwt: await authService.signIn(UserDto.transform(user)), user }; }; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 713860307..e2eef9895 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -7,6 +7,6 @@ "@/*": ["./src/*"] } }, - "include": ["src", "test"], + "include": ["src", "test", "integration-test"], "exclude": ["node_modules", "dist"] } diff --git a/apps/cli/docker-compose.infra-amd64.yml b/apps/cli/docker-compose.infra-amd64.yml index bba018796..927373be6 100644 --- a/apps/cli/docker-compose.infra-amd64.yml +++ b/apps/cli/docker-compose.infra-amd64.yml @@ -4,7 +4,11 @@ services: image: mysql:8.0.39 platform: linux/amd64 restart: always - command: [--default-authentication-plugin=mysql_native_password] + command: + [ + '--default-authentication-plugin=mysql_native_password', + '--collation-server=utf8mb4_bin', + ] environment: MYSQL_ROOT_PASSWORD: userfeedback MYSQL_DATABASE: userfeedback @@ -24,7 +28,11 @@ services: image: mysql:8.0.39 platform: linux/amd64 restart: always - command: [--default-authentication-plugin=mysql_native_password] + command: + [ + '--default-authentication-plugin=mysql_native_password', + '--collation-server=utf8mb4_bin', + ] environment: MYSQL_ROOT_PASSWORD: userfeedback MYSQL_DATABASE: e2e diff --git a/apps/cli/docker-compose.infra-arm64.yml b/apps/cli/docker-compose.infra-arm64.yml index 0e018a0a3..4fbfe2d13 100644 --- a/apps/cli/docker-compose.infra-arm64.yml +++ b/apps/cli/docker-compose.infra-arm64.yml @@ -4,7 +4,11 @@ services: image: mysql:8.0.39 platform: linux/arm64/v8 restart: always - command: [--default-authentication-plugin=mysql_native_password] + command: + [ + '--default-authentication-plugin=mysql_native_password', + '--collation-server=utf8mb4_bin', + ] environment: MYSQL_ROOT_PASSWORD: userfeedback MYSQL_DATABASE: userfeedback @@ -24,7 +28,11 @@ services: image: mysql:8.0.39 platform: linux/arm64/v8 restart: always - command: [--default-authentication-plugin=mysql_native_password] + command: + [ + '--default-authentication-plugin=mysql_native_password', + '--collation-server=utf8mb4_bin', + ] environment: MYSQL_ROOT_PASSWORD: userfeedback MYSQL_DATABASE: e2e diff --git a/apps/web/jest.polyfills.js b/apps/web/jest.polyfills.js index 0efcb71f5..4f530eff1 100644 --- a/apps/web/jest.polyfills.js +++ b/apps/web/jest.polyfills.js @@ -1,6 +1,6 @@ -// https://mswjs.io/docs/faq/#requestresponsetextencoder-is-not-defined-jest -// https://github.com/mswjs/msw/discussions/1934 -const { TextDecoder, TextEncoder, ReadableStream } = require('node:util'); +// https://github.com/mswjs/msw/issues/1916 +const { TextDecoder, TextEncoder } = require('node:util'); +const { ReadableStream } = require('node:stream/web'); // <--- this did the magic Object.defineProperties(globalThis, { TextDecoder: { value: TextDecoder }, @@ -8,11 +8,15 @@ Object.defineProperties(globalThis, { ReadableStream: { value: ReadableStream }, }); -const { fetch, Headers, Request, Response } = require('undici'); +const { Blob, File } = require('node:buffer'); +const { fetch, Response, Request, FormData, Headers } = require('undici'); Object.defineProperties(globalThis, { fetch: { value: fetch, writable: true }, + Blob: { value: Blob }, + File: { value: File }, Headers: { value: Headers }, + FormData: { value: FormData }, Request: { value: Request }, Response: { value: Response }, }); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 4f11a03dc..a4a7b3f5c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index df055f63f..5f731b1c8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,10 +32,10 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@floating-ui/react": "^0.26.12", - "@headlessui/react": "2.1.3", + "@headlessui/react": "2.1.9", "@headlessui/tailwindcss": "^0.2.0", "@hookform/resolvers": "^3.3.4", - "@mui/base": "5.0.0-beta.40", + "@mui/base": "5.0.0-beta.58", "@t3-oss/env-nextjs": "^0.11.0", "@tanstack/react-query": "^5.31.0", "@tanstack/react-table": "^8.16.0", @@ -49,7 +49,7 @@ "clsx": "^2.1.0", "cookies-next": "^4.1.1", "countries-and-timezones": "^3.6.0", - "date-fns": "^3.6.0", + "date-fns": "^4.0.0", "dayjs": "^1.11.10", "framer-motion": "^11.1.7", "i18next": "^23.11.5", @@ -62,7 +62,7 @@ "pino": "^9.0.0", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", - "react-datepicker": "^6.9.0", + "react-datepicker": "^7.0.0", "react-dom": "^18.2.0", "react-hook-form": "^7.51.3", "react-hot-toast": "^2.4.1", @@ -87,17 +87,16 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", - "@types/node": "20.16.2", + "@types/node": "20.16.11", "@types/react": "^18.2.79", "@types/react-beautiful-dnd": "^13.1.8", - "@types/react-datepicker": "^6.0.0", + "@types/react-datepicker": "^7.0.0", "@types/react-dom": "^18.2.25", "@ufb/eslint-config": "workspace:*", "@ufb/prettier-config": "workspace:*", "@ufb/tsconfig": "workspace:*", "autoprefixer": "^10.4.19", "eslint": "catalog:", - "prettier": "catalog:", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jiti": "^1.21.6", @@ -106,9 +105,10 @@ "node-mocks-http": "^1.14.1", "openapi-typescript": "^7.0.0", "postcss": "^8.4.38", + "prettier": "catalog:", "tailwindcss": "^3.4.3", "ts-toolbelt": "^9.6.0", "typescript": "catalog:", - "undici": "~5.28.4" + "undici": "~6.20.0" } } diff --git a/apps/web/src/entities/dashboard/lib/use-line-chart-data.ts b/apps/web/src/entities/dashboard/lib/use-line-chart-data.ts index fd06c5e90..8839fa0d8 100644 --- a/apps/web/src/entities/dashboard/lib/use-line-chart-data.ts +++ b/apps/web/src/entities/dashboard/lib/use-line-chart-data.ts @@ -65,7 +65,6 @@ const useLineChartData = ( currentDate.subtract(dayCount > 50 ? 6 : 0, 'day'), dayjs(from), ); - if (!prevDate) throw new Error("Can't get prevDate"); const channelData = targetData.reduce( (acc, cur) => { diff --git a/apps/web/src/entities/field/ui/field-setting-popover.ui.tsx b/apps/web/src/entities/field/ui/field-setting-popover.ui.tsx index 810d66e6b..f1f81219c 100644 --- a/apps/web/src/entities/field/ui/field-setting-popover.ui.tsx +++ b/apps/web/src/entities/field/ui/field-setting-popover.ui.tsx @@ -133,20 +133,18 @@ const FieldSettingPopover: React.FC = (props) => { }; const onSubmit = (input: FieldInfo) => { - const checkDuplicatedKey = otherFields.find( - (v) => v.key.toLowerCase() === input.key.toLowerCase(), - ); + const checkDuplicatedKey = otherFields.find((v) => v.key === input.key); if (checkDuplicatedKey) { setError('key', { message: 'Key is duplicated' }); return; } - const checkDuplicatedName = otherFields.find( - (v) => v.name.toLowerCase() === input.name.toLowerCase(), - ); + + const checkDuplicatedName = otherFields.find((v) => v.name === input.name); if (checkDuplicatedName) { setError('name', { message: 'Name is duplicated' }); return; } + onSave({ ...data, ...input }); reset(defaultValues); setOpen(false); diff --git a/apps/web/src/entities/tenant/ui/tenant-info-form.ui.tsx b/apps/web/src/entities/tenant/ui/tenant-info-form.ui.tsx index ef66c4a8e..3990609d7 100644 --- a/apps/web/src/entities/tenant/ui/tenant-info-form.ui.tsx +++ b/apps/web/src/entities/tenant/ui/tenant-info-form.ui.tsx @@ -27,7 +27,7 @@ const TenantInfoForm: React.FC = () => { return (
- +