/** * 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; let optionRepo: Repository; 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); fieldRepo = module.get(getRepositoryToken(FieldEntity)); optionRepo = module.get(getRepositoryToken(OptionEntity)); fieldMySQLService = module.get(FieldMySQLService); osRepository = module.get(OpensearchRepository); configService = module.get(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); }); }); });