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,774 @@
/**
* 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 { faker } from '@faker-js/faker';
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
import type { Repository } from 'typeorm';
import { FieldFormatEnum, isSelectFieldFormat } from '@/common/enums';
import { OpensearchRepository } from '@/common/repositories';
import { createFieldDto, updateFieldDto } from '@/test-utils/fixtures';
import { TestConfig } from '@/test-utils/util-functions';
import { FieldServiceProviders } from '../../../../test-utils/providers/field.service.providers';
import { OptionEntity } from '../option/option.entity';
import type { CreateFieldDto, ReplaceFieldDto } from './dtos';
import { CreateManyFieldsDto, ReplaceManyFieldsDto } from './dtos';
import {
FieldKeyDuplicatedException,
FieldNameDuplicatedException,
} from './exceptions';
import { FieldEntity } from './field.entity';
import { FieldMySQLService } from './field.mysql.service';
import { FieldService } from './field.service';
const countSelect = (prev: number, curr: CreateFieldDto): number => {
return (
isSelectFieldFormat(curr.format) &&
curr.options &&
curr.options.length > 0
) ?
prev + 1
: prev;
};
describe('FieldService suite', () => {
let fieldService: FieldService;
let fieldRepo: Repository<FieldEntity>;
let optionRepo: Repository<OptionEntity>;
let fieldMySQLService: FieldMySQLService;
let osRepository: OpensearchRepository;
let configService: ConfigService;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [TestConfig],
providers: FieldServiceProviders,
}).compile();
fieldService = module.get<FieldService>(FieldService);
fieldRepo = module.get(getRepositoryToken(FieldEntity));
optionRepo = module.get(getRepositoryToken(OptionEntity));
fieldMySQLService = module.get<FieldMySQLService>(FieldMySQLService);
osRepository = module.get<OpensearchRepository>(OpensearchRepository);
configService = module.get<ConfigService>(ConfigService);
});
describe('fieldsToMapping', () => {
it('should create correct mapping for text field', () => {
const fields = [
{
key: 'testText',
format: FieldFormatEnum.text,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testText).toEqual({
type: 'text',
analyzer: 'ngram_analyzer',
search_analyzer: 'ngram_analyzer',
});
});
it('should create correct mapping for keyword field', () => {
const fields = [
{
key: 'testKeyword',
format: FieldFormatEnum.keyword,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testKeyword).toEqual({
type: 'keyword',
});
});
it('should create correct mapping for number field', () => {
const fields = [
{
key: 'testNumber',
format: FieldFormatEnum.number,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testNumber).toEqual({
type: 'integer',
});
});
it('should create correct mapping for select field', () => {
const fields = [
{
key: 'testSelect',
format: FieldFormatEnum.select,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testSelect).toEqual({
type: 'keyword',
});
});
it('should create correct mapping for multiSelect field', () => {
const fields = [
{
key: 'testMultiSelect',
format: FieldFormatEnum.multiSelect,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testMultiSelect).toEqual({
type: 'keyword',
});
});
it('should create correct mapping for date field', () => {
const fields = [
{
key: 'testDate',
format: FieldFormatEnum.date,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testDate).toEqual({
type: 'date',
format:
'yyyy-MM-dd HH:mm:ss||yyyy-MM-dd HH:mm:ssZ||yyyy-MM-dd HH:mm:ssZZZZZ||yyyy-MM-dd||epoch_millis||strict_date_optional_time',
});
});
it('should create correct mapping for images field', () => {
const fields = [
{
key: 'testImages',
format: FieldFormatEnum.images,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testImages).toEqual({
type: 'text',
analyzer: 'ngram_analyzer',
search_analyzer: 'ngram_analyzer',
});
});
it('should create correct mapping for aiField', () => {
const fields = [
{
key: 'testAiField',
format: FieldFormatEnum.aiField,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testAiField).toEqual({
type: 'object',
properties: {
status: { type: 'text' },
message: {
type: 'text',
analyzer: 'ngram_analyzer',
search_analyzer: 'ngram_analyzer',
},
},
});
});
it('should create mapping for multiple fields', () => {
const fields = [
{
key: 'field1',
format: FieldFormatEnum.text,
} as FieldEntity,
{
key: 'field2',
format: FieldFormatEnum.number,
} as FieldEntity,
{
key: 'field3',
format: FieldFormatEnum.keyword,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(Object.keys(mapping)).toHaveLength(3);
expect(mapping.field1.type).toBe('text');
expect(mapping.field2.type).toBe('integer');
expect(mapping.field3.type).toBe('keyword');
});
it('should return empty object for empty fields array', () => {
const mapping = fieldService.fieldsToMapping([]);
expect(mapping).toEqual({});
});
});
describe('createMany', () => {
it('creating many fields succeeds with valid inputs', async () => {
const channelId = faker.number.int();
const fieldCount = faker.number.int({ min: 1, max: 10 });
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = Array.from({ length: fieldCount }).map(createFieldDto);
const selectFieldCount = dto.fields.reduce(countSelect, 0);
jest.spyOn(fieldRepo, 'save');
jest.spyOn(optionRepo, 'save');
const fields = await fieldService.createMany(dto);
expect(fields.length).toBe(fieldCount + 4);
expect(optionRepo.save).toHaveBeenCalledTimes(selectFieldCount);
});
it('creating many fields with OpenSearch enabled should call putMappings', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = [createFieldDto()];
const mockFields = [createFieldDto({})] as FieldEntity[];
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(true);
jest
.spyOn(osRepository, 'putMappings')
.mockResolvedValue({} as Indices_PutMapping_Response);
await fieldService.createMany(dto);
expect(osRepository.putMappings).toHaveBeenCalledWith({
index: channelId.toString(),
mappings: expect.any(Object),
});
});
it('creating many fields with OpenSearch disabled should not call putMappings', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = [createFieldDto()];
const mockFields = [createFieldDto({})] as FieldEntity[];
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(false);
jest
.spyOn(osRepository, 'putMappings')
.mockResolvedValue({} as Indices_PutMapping_Response);
await fieldService.createMany(dto);
expect(osRepository.putMappings).not.toHaveBeenCalled();
});
it('creating many fields fails with duplicate names', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = Array.from({ length: 2 }).map(() =>
createFieldDto({ name: 'duplicateName' }),
);
await expect(fieldService.createMany(dto)).rejects.toThrow(
FieldNameDuplicatedException,
);
});
it('creating many fields fails with duplicate keys', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = Array.from({ length: 2 }).map(() =>
createFieldDto({ key: 'duplicateKey' }),
);
await expect(fieldService.createMany(dto)).rejects.toThrow(
FieldKeyDuplicatedException,
);
});
it('creating many fields fails with options in non-select format field', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = Array.from({ length: 1 }).map(() =>
createFieldDto({
format: FieldFormatEnum.text,
options: [
{ key: faker.string.sample(), name: faker.string.sample() },
],
}),
);
await expect(fieldService.createMany(dto)).rejects.toThrow(
new BadRequestException('only select format field has options'),
);
});
it('creating many fields fails with invalid field key containing special characters', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = [createFieldDto({ key: 'invalid-key!' })];
await expect(fieldService.createMany(dto)).rejects.toThrow(
new BadRequestException(
'field key only should contain alphanumeric and underscore',
),
);
});
it('creating many fields fails with reserved field name', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = [createFieldDto({ name: 'ID' })];
await expect(fieldService.createMany(dto)).rejects.toThrow(
new BadRequestException('name is rejected'),
);
});
it('creating many fields fails with reserved field key', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = [createFieldDto({ key: 'id' })];
await expect(fieldService.createMany(dto)).rejects.toThrow(
new BadRequestException('key is rejected'),
);
});
});
describe('replaceMany', () => {
it('replacing many fields succeeds with valid inputs', async () => {
const channelId = faker.number.int();
const updatingFieldDtos = Array.from({
length: faker.number.int({ min: 1, max: 10 }),
}).map(updateFieldDto);
const creatingFieldDtos = Array.from({
length: faker.number.int({ min: 1, max: 10 }),
}).map(createFieldDto);
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...creatingFieldDtos, ...updatingFieldDtos];
jest
.spyOn(fieldRepo, 'findBy')
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
jest.spyOn(fieldRepo, 'save');
await fieldService.replaceMany(dto);
expect(fieldRepo.save).toHaveBeenCalledTimes(
updatingFieldDtos.length + creatingFieldDtos.length,
);
});
it('replacing many fields with OpenSearch enabled should call putMappings', async () => {
const channelId = faker.number.int();
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [updateFieldDto({})];
const mockFields = [createFieldDto({})] as FieldEntity[];
jest
.spyOn(fieldMySQLService, 'replaceMany')
.mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(true);
jest
.spyOn(osRepository, 'putMappings')
.mockResolvedValue({} as Indices_PutMapping_Response);
await fieldService.replaceMany(dto);
expect(osRepository.putMappings).toHaveBeenCalledWith({
index: channelId.toString(),
mappings: expect.any(Object),
});
});
it('replacing many fields with OpenSearch disabled should not call putMappings', async () => {
const channelId = faker.number.int();
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [updateFieldDto({})];
const mockFields = [createFieldDto({})] as FieldEntity[];
jest
.spyOn(fieldMySQLService, 'replaceMany')
.mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(false);
jest
.spyOn(osRepository, 'putMappings')
.mockResolvedValue({} as Indices_PutMapping_Response);
await fieldService.replaceMany(dto);
expect(osRepository.putMappings).not.toHaveBeenCalled();
});
it('replacing many fields fails with duplicate names', async () => {
const channelId = faker.number.int();
const updatingFieldDtos = Array.from({
length: 2,
}).map(() => updateFieldDto({ name: 'duplicateName' }));
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...updatingFieldDtos];
jest
.spyOn(fieldRepo, 'findBy')
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
FieldNameDuplicatedException,
);
});
it('replacing many fields fails with duplicate keys', async () => {
const channelId = faker.number.int();
const updatingFieldDtos = Array.from({
length: 2,
}).map(() => updateFieldDto({ key: 'duplicateKey' }));
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...updatingFieldDtos];
jest
.spyOn(fieldRepo, 'findBy')
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
FieldKeyDuplicatedException,
);
});
it('replacing many fields fails with options in non-select format field', async () => {
const channelId = faker.number.int();
const updatingFieldDtos = Array.from({
length: 1,
}).map(() =>
updateFieldDto({
format: FieldFormatEnum.text,
options: [
{ key: faker.string.sample(), name: faker.string.sample() },
],
}),
);
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...updatingFieldDtos];
jest
.spyOn(fieldRepo, 'findBy')
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
new BadRequestException('only select format field has options'),
);
});
it('replacing many fields fails with a nonexistent field', async () => {
const channelId = faker.number.int();
const updatingFieldDtos = Array.from({
length: faker.number.int({ min: 1, max: 10 }),
}).map(updateFieldDto);
const creatingFieldDtos = Array.from({
length: faker.number.int({ min: 1, max: 10 }),
}).map(createFieldDto);
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...creatingFieldDtos, ...updatingFieldDtos];
jest
.spyOn(fieldRepo, 'findBy')
.mockResolvedValue(updatingFieldDtos.splice(1) as FieldEntity[]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
new BadRequestException('field must be included'),
);
});
it('replacing many fields fails with a format change', async () => {
const channelId = faker.number.int();
const updatingFieldDtos = Array.from({
length: faker.number.int({ min: 1, max: 10 }),
}).map(() => updateFieldDto({ format: FieldFormatEnum.keyword }));
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = JSON.parse(
JSON.stringify(updatingFieldDtos),
) as ReplaceFieldDto[];
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue(
updatingFieldDtos.map((field) => {
field.format = FieldFormatEnum.text;
return field;
}) as FieldEntity[],
);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
new BadRequestException('field format cannot be changed'),
);
});
it('replacing many fields fails with a key change', async () => {
const channelId = faker.number.int();
const updatingFieldDtos = Array.from({
length: faker.number.int({ min: 1, max: 10 }),
}).map(updateFieldDto);
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = JSON.parse(
JSON.stringify(updatingFieldDtos),
) as ReplaceFieldDto[];
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue(
updatingFieldDtos.map((field) => {
field.key = faker.string.sample();
return field;
}) as FieldEntity[],
);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
new BadRequestException('field key cannot be changed'),
);
});
it('replacing many fields fails with invalid field key containing special characters', async () => {
const channelId = faker.number.int();
const creatingFieldDtos = [createFieldDto({ key: 'invalid-key!' })];
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...creatingFieldDtos];
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue([]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
new BadRequestException(
'field key only should contain alphanumeric and underscore',
),
);
});
it('replacing many fields fails with reserved field name in creating fields', async () => {
const channelId = faker.number.int();
const creatingFieldDtos = [createFieldDto({ name: 'ID' })];
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...creatingFieldDtos];
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue([]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
new BadRequestException('name is rejected'),
);
});
it('replacing many fields fails with reserved field key in creating fields', async () => {
const channelId = faker.number.int();
const creatingFieldDtos = [createFieldDto({ key: 'id' })];
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...creatingFieldDtos];
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue([]);
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
new BadRequestException('key is rejected'),
);
});
});
describe('findByChannelId', () => {
it('should return fields for given channel id', async () => {
const channelId = faker.number.int();
const mockFields = [createFieldDto(), createFieldDto()] as FieldEntity[];
jest
.spyOn(fieldMySQLService, 'findByChannelId')
.mockResolvedValue(mockFields);
const result = await fieldService.findByChannelId({ channelId });
expect(fieldMySQLService.findByChannelId).toHaveBeenCalledWith({
channelId,
});
expect(result).toEqual(mockFields);
});
it('should return empty array when no fields found', async () => {
const channelId = faker.number.int();
jest.spyOn(fieldMySQLService, 'findByChannelId').mockResolvedValue([]);
const result = await fieldService.findByChannelId({ channelId });
expect(result).toEqual([]);
});
});
describe('findByIds', () => {
it('should return fields for given ids', async () => {
const ids = [faker.number.int(), faker.number.int()];
const mockFields = [createFieldDto(), createFieldDto()] as FieldEntity[];
jest.spyOn(fieldMySQLService, 'findByIds').mockResolvedValue(mockFields);
const result = await fieldService.findByIds(ids);
expect(fieldMySQLService.findByIds).toHaveBeenCalledWith(ids);
expect(result).toEqual(mockFields);
});
it('should return empty array when no fields found', async () => {
const ids = [faker.number.int()];
jest.spyOn(fieldMySQLService, 'findByIds').mockResolvedValue([]);
const result = await fieldService.findByIds(ids);
expect(result).toEqual([]);
});
it('should handle empty ids array', async () => {
jest.spyOn(fieldMySQLService, 'findByIds').mockResolvedValue([]);
const result = await fieldService.findByIds([]);
expect(fieldMySQLService.findByIds).toHaveBeenCalledWith([]);
expect(result).toEqual([]);
});
});
describe('edge cases', () => {
it('createMany should handle empty fields array', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = [];
const mockFields = [] as FieldEntity[];
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(false);
const result = await fieldService.createMany(dto);
expect(result).toEqual([]);
expect(fieldMySQLService.createMany).toHaveBeenCalledWith(dto);
});
it('replaceMany should handle empty fields array', async () => {
const channelId = faker.number.int();
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [];
const mockFields = [] as FieldEntity[];
jest
.spyOn(fieldMySQLService, 'replaceMany')
.mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(false);
await fieldService.replaceMany(dto);
expect(fieldMySQLService.replaceMany).toHaveBeenCalledWith(dto);
});
it('createMany should handle null channelId', async () => {
const dto = new CreateManyFieldsDto();
dto.channelId = null as unknown as number;
dto.fields = [createFieldDto()];
const mockFields = [createFieldDto({})] as FieldEntity[];
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(false);
const result = await fieldService.createMany(dto);
expect(result).toEqual(mockFields);
});
it('fieldsToMapping should handle fields with null/undefined properties', () => {
const fields = [
{
key: 'testField',
format: FieldFormatEnum.text,
} as FieldEntity,
{
key: '',
format: FieldFormatEnum.keyword,
} as FieldEntity,
];
const mapping = fieldService.fieldsToMapping(fields);
expect(mapping.testField).toBeDefined();
expect(mapping['']).toBeDefined();
});
it('createMany should handle fields with empty options array', async () => {
const channelId = faker.number.int();
const dto = new CreateManyFieldsDto();
dto.channelId = channelId;
dto.fields = [
createFieldDto({
format: FieldFormatEnum.select,
options: [],
}),
];
const mockFields = [createFieldDto({})] as FieldEntity[];
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(false);
const result = await fieldService.createMany(dto);
expect(result).toEqual(mockFields);
});
it('replaceMany should handle mixed creating and updating fields', async () => {
const channelId = faker.number.int();
const creatingFieldDtos = [createFieldDto({})];
const updatingFieldDtos = [updateFieldDto({})];
const dto = new ReplaceManyFieldsDto();
dto.channelId = channelId;
dto.fields = [...creatingFieldDtos, ...updatingFieldDtos];
const mockFields = [createFieldDto({})] as FieldEntity[];
jest
.spyOn(fieldMySQLService, 'replaceMany')
.mockResolvedValue(mockFields);
jest.spyOn(configService, 'get').mockReturnValue(false);
await fieldService.replaceMany(dto);
expect(fieldMySQLService.replaceMany).toHaveBeenCalledWith(dto);
});
});
});