This commit is contained in:
commit
33453ecc55
3475 files changed
+850363
No files matched your search
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Type } from '@nestjs/common';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
|
||||
import { PaginationResponseDto } from '../dtos/pagination-response.dto';
|
||||
|
||||
export const ApiOkResponsePagination = <Dto extends Type<unknown>>(dto: Dto) =>
|
||||
applyDecorators(
|
||||
ApiExtraModels(PaginationResponseDto, dto),
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(PaginationResponseDto) },
|
||||
{
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: getSchemaPath(dto) },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 { applyDecorators } from '@nestjs/common';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiInternalServerErrorResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { ApiErrorResponseDto } from '../dtos';
|
||||
|
||||
export const ApiStandardErrorResponses = () =>
|
||||
applyDecorators(
|
||||
ApiBadRequestResponse({ type: ApiErrorResponseDto }),
|
||||
ApiUnauthorizedResponse({ type: ApiErrorResponseDto }),
|
||||
ApiForbiddenResponse({ type: ApiErrorResponseDto }),
|
||||
ApiNotFoundResponse({ type: ApiErrorResponseDto }),
|
||||
ApiInternalServerErrorResponse({ type: ApiErrorResponseDto }),
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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 { IsString } from 'class-validator';
|
||||
|
||||
import DtoValidator from './dto-validator';
|
||||
|
||||
class Dto {
|
||||
@IsString()
|
||||
str: string;
|
||||
}
|
||||
|
||||
class TestClass {
|
||||
@DtoValidator()
|
||||
noParam() {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
dtoParam(_dto: Dto) {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
dtosParam(_dtos: Dto[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
compositionParam(_a: any, _dtos: Dto) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
describe('dto validator', () => {
|
||||
let instance: TestClass;
|
||||
beforeEach(() => {
|
||||
instance = new TestClass();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
instance.noParam();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = 'test';
|
||||
instance.dtoParam(dto);
|
||||
const dto2 = new Dto();
|
||||
void expect(instance.dtoParam(dto2)).rejects.toThrow();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = 'test';
|
||||
instance.dtosParam([dto]);
|
||||
const dto2 = new Dto();
|
||||
void expect(instance.dtosParam([dto2])).rejects.toThrow();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = '123';
|
||||
instance.compositionParam([1], dto);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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 { InternalServerErrorException } from '@nestjs/common';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
type Method = (...args: object[]) => any;
|
||||
|
||||
const DtoValidator =
|
||||
() =>
|
||||
(
|
||||
target: unknown,
|
||||
propName: string,
|
||||
descriptor: TypedPropertyDescriptor<any>,
|
||||
) => {
|
||||
const methodRef = descriptor.value as Method;
|
||||
|
||||
descriptor.value = async function (...args: object[]): Promise<any> {
|
||||
for (const arg of args) {
|
||||
let errors: ValidationError[] = [];
|
||||
|
||||
if (!Array.isArray(arg) && typeof arg === 'object') {
|
||||
errors = await validate(arg);
|
||||
} else if (
|
||||
Array.isArray(arg) &&
|
||||
arg.length > 0 &&
|
||||
typeof arg[0] === 'object'
|
||||
) {
|
||||
errors = (
|
||||
await Promise.all(
|
||||
arg.map(async (item: object) => await validate(item)),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new InternalServerErrorException(errors);
|
||||
}
|
||||
}
|
||||
return (await methodRef.call(this, ...args)) as object;
|
||||
};
|
||||
return descriptor;
|
||||
};
|
||||
export default DtoValidator;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { default as DtoValidator } from './dto-validator';
|
||||
export { ApiOkResponsePagination } from './api-ok-response-pagination.decorator';
|
||||
export { ApiStandardErrorResponses } from './api-standard-error-responses.decorator';
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 { ValidationError, Validator } from 'class-validator';
|
||||
|
||||
import { IsPassword } from './is-password';
|
||||
|
||||
class IsPasswordTest {
|
||||
@IsPassword()
|
||||
password: any;
|
||||
}
|
||||
|
||||
describe('IsPassword decorator', () => {
|
||||
it('', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.string.sample(
|
||||
faker.number.int({ min: 8, max: 15 }),
|
||||
);
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors) => {
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
it('minLength', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.string.sample(
|
||||
faker.number.int({ min: 0, max: 7 }),
|
||||
);
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('minLength');
|
||||
});
|
||||
});
|
||||
|
||||
it('isString', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.number.int({ min: 10000000, max: 99999999 });
|
||||
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||
});
|
||||
});
|
||||
|
||||
it('isString, minLength', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.number.int({ min: 0, max: 9999999 });
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||
expect(Object.keys(errors[0].constraints ?? {})[1]).toEqual('minLength');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 { applyDecorators } from '@nestjs/common';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export function IsPassword() {
|
||||
return applyDecorators(IsString(), MinLength(8));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ApiErrorResponseDto {
|
||||
@ApiProperty({ example: 'BAD_REQUEST' })
|
||||
code: string;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ type: 'string', example: 'Invalid channel id' },
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['title should not be empty'],
|
||||
},
|
||||
],
|
||||
})
|
||||
message: string | string[];
|
||||
|
||||
@ApiProperty({ example: 'Bad Request' })
|
||||
error: string;
|
||||
|
||||
@ApiProperty({ example: 400 })
|
||||
statusCode: number;
|
||||
|
||||
@ApiProperty({ example: '/api/admin/projects/1/channels/1/feedbacks' })
|
||||
path: string;
|
||||
|
||||
@ApiPropertyOptional({ type: Object, example: { field: 'email' } })
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const dynamicFieldValueSchema = {
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
{ type: 'array', items: { type: 'string' } },
|
||||
{ type: 'null' },
|
||||
],
|
||||
description:
|
||||
'The value type is determined by the channel field format. Select and date fields may be null.',
|
||||
};
|
||||
|
||||
export const DYNAMIC_FEEDBACK_REQUEST_SCHEMA = {
|
||||
type: 'object',
|
||||
description:
|
||||
'Dynamic feedback fields. Use GET /projects/{projectId}/channels/{channelId}/fields to discover the accepted keys, formats, and select options.',
|
||||
additionalProperties: dynamicFieldValueSchema,
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Feedback title' },
|
||||
contents: { type: 'string', example: 'Feedback contents' },
|
||||
Category: { type: 'string', example: 'ERROR_QNA' },
|
||||
IP: { type: 'string', example: '192.168.0.10' },
|
||||
MAC_address: { type: 'string', example: '00:1A:2B:3C:4D:5E' },
|
||||
issueNames: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'Optional issue names to connect. This control field is used for issue linking and is not saved as a feedback field.',
|
||||
example: ['Login error'],
|
||||
},
|
||||
},
|
||||
example: {
|
||||
title: 'Feedback title',
|
||||
contents: 'Feedback contents',
|
||||
Category: 'ERROR_QNA',
|
||||
IP: '192.168.0.10',
|
||||
MAC_address: '00:1A:2B:3C:4D:5E',
|
||||
issueNames: ['Login error'],
|
||||
},
|
||||
};
|
||||
|
||||
export const DYNAMIC_FEEDBACK_MULTIPART_SCHEMA = {
|
||||
type: 'object',
|
||||
description:
|
||||
'Multipart feedback input. Non-file parts use the channel field keys; the images field accepts repeated binary files of any type.',
|
||||
additionalProperties: { type: 'string' },
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Feedback title' },
|
||||
contents: { type: 'string', example: 'Feedback contents' },
|
||||
issueNames: {
|
||||
type: 'string',
|
||||
description: 'JSON array or repeated value depending on the client.',
|
||||
example: '["Login error"]',
|
||||
},
|
||||
images: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'binary' },
|
||||
description: 'Repeated file attachments. Any file type is accepted within the upload limits.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DYNAMIC_FEEDBACK_ITEM_SCHEMA = {
|
||||
type: 'object',
|
||||
description:
|
||||
'Feedback item. Channel field values are returned as top-level dynamic properties.',
|
||||
additionalProperties: dynamicFieldValueSchema,
|
||||
properties: {
|
||||
id: { type: 'number', example: 34 },
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
example: '2026-08-25T09:00:00.000Z',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
example: '2026-08-25T09:30:00.000Z',
|
||||
},
|
||||
issues: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number', example: 13 },
|
||||
name: { type: 'string', example: 'Login error' },
|
||||
status: { type: 'string', example: 'IN_PROGRESS' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DYNAMIC_FEEDBACK_PAGINATION_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: DYNAMIC_FEEDBACK_ITEM_SCHEMA,
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
itemCount: { type: 'number', example: 10 },
|
||||
totalItems: { type: 'number', example: 35 },
|
||||
itemsPerPage: { type: 'number', example: 10 },
|
||||
currentPage: { type: 'number', example: 1 },
|
||||
totalPages: { type: 'number', example: 4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DYNAMIC_FEEDBACK_QUERY_VALUE_SCHEMA = {
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
{
|
||||
type: 'array',
|
||||
items: { oneOf: [{ type: 'string' }, { type: 'number' }] },
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
gte: { type: 'string', example: '2026-08-01' },
|
||||
lt: { type: 'string', example: '2026-09-01' },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { PaginationDto } from './pagination.dto';
|
||||
export { PaginationRequestDto } from './pagination-request.dto';
|
||||
export { PaginationResponseDto } from './pagination-response.dto';
|
||||
export { TimeRange } from './time-range.dto';
|
||||
export { ApiErrorResponseDto } from './api-error-response.dto';
|
||||
export * from './dynamic-feedback.dto';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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 { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||
|
||||
import { toNumber } from '@/common/helper/cast.helper';
|
||||
|
||||
export class PaginationRequestDto {
|
||||
@Transform(({ value }: { value: string }) =>
|
||||
toNumber(value, { default: 10, min: 1 }),
|
||||
)
|
||||
@ApiProperty({ required: false, minimum: 1, default: 10, example: 10 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit: number;
|
||||
|
||||
@Transform(({ value }: { value: string }) =>
|
||||
toNumber(value, { default: 1, min: 1 }),
|
||||
)
|
||||
@ApiProperty({ required: false, minimum: 1, default: 1, example: 1 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page: number;
|
||||
|
||||
constructor(limit = 10, page = 1) {
|
||||
this.limit = limit;
|
||||
this.page = page;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import type { IPaginationMeta, Pagination } from 'nestjs-typeorm-paginate';
|
||||
|
||||
class PaginationMetaDto implements IPaginationMeta {
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
itemCount: number;
|
||||
|
||||
@ApiProperty({ example: 100 })
|
||||
@Expose()
|
||||
totalItems?: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
itemsPerPage: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
totalPages?: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@Expose()
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export abstract class PaginationResponseDto<T> implements Pagination<T> {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
@Type(() => PaginationMetaDto)
|
||||
meta: PaginationMetaDto;
|
||||
|
||||
abstract items: T[];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class PaginationDto {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TimeRange {
|
||||
@ApiProperty({ name: 'gte (UTC)' })
|
||||
gte: string;
|
||||
@ApiProperty({ name: 'lt (UTC)' })
|
||||
lt: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 { DateTime } from 'luxon';
|
||||
import {
|
||||
BeforeInsert,
|
||||
BeforeUpdate,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export abstract class CommonEntity {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
beforeInsertHook() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!this.createdAt) {
|
||||
this.createdAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
this.updatedAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
|
||||
@BeforeUpdate()
|
||||
beforeUpdateHook() {
|
||||
this.updatedAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { CommonEntity } from './common.entity';
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export enum AIPromptStatusEnum {
|
||||
success = 'success',
|
||||
error = 'error',
|
||||
loading = 'loading',
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export enum AIProvidersEnum {
|
||||
OPEN_AI = 'OPEN_AI',
|
||||
GEMINI = 'GEMINI',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum EventStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum EventTypeEnum {
|
||||
FEEDBACK_CREATION = 'FEEDBACK_CREATION',
|
||||
ISSUE_CREATION = 'ISSUE_CREATION',
|
||||
ISSUE_STATUS_CHANGE = 'ISSUE_STATUS_CHANGE',
|
||||
ISSUE_ADDITION = 'ISSUE_ADDITION',
|
||||
FEEDBACK_STATUS_CHANGE = 'FEEDBACK_STATUS_CHANGE',
|
||||
FEEDBACK_COMMENT_CREATION = 'FEEDBACK_COMMENT_CREATION',
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum FeedbackPriorityEnum {
|
||||
LOW = 'LOW',
|
||||
MEDIUM = 'MEDIUM',
|
||||
HIGH = 'HIGH',
|
||||
CRITICAL = 'CRITICAL',
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum FeedbackStatusEnum {
|
||||
INIT = 'INIT',
|
||||
ON_REVIEW = 'ON_REVIEW',
|
||||
DETAILED_REVIEW = 'DETAILED_REVIEW',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
RESOLVED = 'RESOLVED',
|
||||
PENDING = 'PENDING',
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum FieldFormatEnum {
|
||||
text = 'text',
|
||||
keyword = 'keyword',
|
||||
number = 'number',
|
||||
select = 'select',
|
||||
multiSelect = 'multiSelect',
|
||||
date = 'date',
|
||||
images = 'images',
|
||||
aiField = 'aiField',
|
||||
}
|
||||
|
||||
export function isSelectFieldFormat(type: FieldFormatEnum) {
|
||||
return [FieldFormatEnum.select, FieldFormatEnum.multiSelect].includes(type);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum FieldPropertyEnum {
|
||||
READ_ONLY = 'READ_ONLY',
|
||||
EDITABLE = 'EDITABLE',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum FieldStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { FieldFormatEnum, isSelectFieldFormat } from './field-format.enum';
|
||||
export { FieldPropertyEnum } from './field-property.enum';
|
||||
export { FieldStatusEnum } from './field-status.enum';
|
||||
export { IssueStatusEnum } from './issue-status.enum';
|
||||
export { FeedbackStatusEnum } from './feedback-status.enum';
|
||||
export { FeedbackPriorityEnum } from './feedback-priority.enum';
|
||||
export { SortMethodEnum } from './sort-method.enum';
|
||||
export { EventTypeEnum } from './event-type.enum';
|
||||
export { EventStatusEnum } from './event-status.enum';
|
||||
export { WebhookStatusEnum } from './webhook-status.enum';
|
||||
export { QueryV2ConditionsEnum } from './query-v2-conditions.enum';
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum IssueStatusEnum {
|
||||
INIT = 'INIT',
|
||||
ON_REVIEW = 'ON_REVIEW',
|
||||
DETAILED_REVIEW = 'DETAILED_REVIEW',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
RESOLVED = 'RESOLVED',
|
||||
PENDING = 'PENDING',
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum QueryV2ConditionsEnum {
|
||||
CONTAINS = 'CONTAINS',
|
||||
IS = 'IS',
|
||||
BETWEEN = 'BETWEEN',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum SortMethodEnum {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export enum WebhookStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ArgumentsHost } from '@nestjs/common';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { HttpExceptionFilter } from './http-exception.filter';
|
||||
|
||||
describe('HttpExceptionFilter', () => {
|
||||
let filter: HttpExceptionFilter;
|
||||
let mockRequest: FastifyRequest;
|
||||
let mockResponse: FastifyReply;
|
||||
let mockArgumentsHost: ArgumentsHost;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [HttpExceptionFilter],
|
||||
}).compile();
|
||||
|
||||
filter = module.get<HttpExceptionFilter>(HttpExceptionFilter);
|
||||
|
||||
// Mock FastifyRequest
|
||||
mockRequest = {
|
||||
url: '/test-endpoint',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
query: {},
|
||||
params: {},
|
||||
body: {},
|
||||
} as FastifyRequest;
|
||||
|
||||
// Mock FastifyReply
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
// Mock ArgumentsHost
|
||||
mockArgumentsHost = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue(mockRequest),
|
||||
getResponse: jest.fn().mockReturnValue(mockResponse),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('catch', () => {
|
||||
it('should handle string exception response', () => {
|
||||
const exception = new HttpException(
|
||||
'Test error message',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Test error message',
|
||||
error: 'BAD_REQUEST',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: 'Validation failed',
|
||||
error: 'Bad Request',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Validation failed',
|
||||
error: 'Bad Request',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different HTTP status codes', () => {
|
||||
const statusCodes = [
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
HttpStatus.FORBIDDEN,
|
||||
HttpStatus.NOT_FOUND,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
];
|
||||
|
||||
statusCodes.forEach((statusCode) => {
|
||||
const exception = new HttpException('Test error', statusCode);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(statusCode);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: HttpStatus[statusCode],
|
||||
message: 'Test error',
|
||||
error: HttpStatus[statusCode],
|
||||
statusCode,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: ['Email is required', 'Password is too short'],
|
||||
error: 'Validation Error',
|
||||
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
details: {
|
||||
field: 'email',
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: ['Email is required', 'Password is too short'],
|
||||
error: 'Validation Error',
|
||||
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
path: '/test-endpoint',
|
||||
details: {
|
||||
field: 'email',
|
||||
value: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty string exception response', () => {
|
||||
const exception = new HttpException('', HttpStatus.NO_CONTENT);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'NO_CONTENT',
|
||||
message: '',
|
||||
error: 'NO_CONTENT',
|
||||
statusCode: HttpStatus.NO_CONTENT,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null exception response', () => {
|
||||
const exception = new HttpException(null as any, HttpStatus.NO_CONTENT);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'NO_CONTENT',
|
||||
message: 'NO_CONTENT',
|
||||
error: 'NO_CONTENT',
|
||||
statusCode: HttpStatus.NO_CONTENT,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined exception response', () => {
|
||||
const exception = new HttpException(
|
||||
undefined as any,
|
||||
HttpStatus.NO_CONTENT,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'NO_CONTENT',
|
||||
message: 'NO_CONTENT',
|
||||
error: 'NO_CONTENT',
|
||||
statusCode: HttpStatus.NO_CONTENT,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different request URLs', () => {
|
||||
const urls = [
|
||||
'/api/users',
|
||||
'/api/projects/123',
|
||||
'/api/auth/login',
|
||||
'/api/feedback?page=1&limit=10',
|
||||
];
|
||||
|
||||
urls.forEach((url) => {
|
||||
Object.assign(mockRequest, { url });
|
||||
const exception = new HttpException(
|
||||
'Test error',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Test error',
|
||||
error: 'BAD_REQUEST',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: 'Complex error',
|
||||
error: 'Internal Server Error',
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
nested: {
|
||||
level1: {
|
||||
level2: {
|
||||
value: 'deep nested value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Complex error',
|
||||
error: 'Internal Server Error',
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
path: '/test-endpoint',
|
||||
details: {
|
||||
nested: {
|
||||
level1: {
|
||||
level2: {
|
||||
value: 'deep nested value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array exception response', () => {
|
||||
const exceptionResponse = ['Error 1', 'Error 2', 'Error 3'];
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'BAD_REQUEST',
|
||||
message: ['Error 1', 'Error 2', 'Error 3'],
|
||||
error: 'BAD_REQUEST',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean exception response', () => {
|
||||
const exception = new HttpException(true as any, HttpStatus.OK);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'OK',
|
||||
message: 'OK',
|
||||
error: 'OK',
|
||||
statusCode: HttpStatus.OK,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle number exception response', () => {
|
||||
const exception = new HttpException(42 as any, HttpStatus.OK);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
code: 'OK',
|
||||
message: 'OK',
|
||||
error: 'OK',
|
||||
statusCode: HttpStatus.OK,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
|
||||
import { Catch, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
type ExceptionResponse = {
|
||||
message?: string | string[];
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
details?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
|
||||
const statusCode = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
this.logger.error({ statusCode, exceptionResponse });
|
||||
const normalizedResponse: ExceptionResponse =
|
||||
typeof exceptionResponse === 'string' ? { message: exceptionResponse }
|
||||
: Array.isArray(exceptionResponse) ?
|
||||
{ message: exceptionResponse.map((item) => String(item)) }
|
||||
: ((exceptionResponse as ExceptionResponse | null) ?? {});
|
||||
const {
|
||||
message: responseMessage,
|
||||
error: responseError,
|
||||
statusCode: _responseStatusCode,
|
||||
details: responseDetails,
|
||||
...extraDetails
|
||||
} = normalizedResponse;
|
||||
const error = responseError ?? this.getHttpErrorName(statusCode);
|
||||
const message = responseMessage ?? this.getHttpErrorName(statusCode);
|
||||
const details =
|
||||
responseDetails ??
|
||||
(Object.keys(extraDetails).length > 0 ? extraDetails : undefined);
|
||||
|
||||
void response.status(statusCode).send({
|
||||
code: this.toErrorCode(error, statusCode),
|
||||
message,
|
||||
error,
|
||||
statusCode,
|
||||
path: request.url,
|
||||
...(details === undefined ?
|
||||
{}
|
||||
: {
|
||||
details,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private getHttpErrorName(statusCode: number): string {
|
||||
return HttpStatus[statusCode] ?? 'HTTP_ERROR';
|
||||
}
|
||||
|
||||
private toErrorCode(error: string, statusCode: number): string {
|
||||
const code = error
|
||||
.trim()
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.toUpperCase();
|
||||
|
||||
return code || this.getHttpErrorName(statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { HttpExceptionFilter } from './http-exception.filter';
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
interface ToNumberOptions {
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function toLowerCase(value: string): string {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
export function trim(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function toDate(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
export function toBoolean(value: string): boolean {
|
||||
value = value.toLowerCase();
|
||||
|
||||
return value === 'true' || value === '1' ? true : false;
|
||||
}
|
||||
|
||||
export function toNumber(value: string, opts: ToNumberOptions = {}): number {
|
||||
let newValue: number = Number.parseInt(value || String(opts.default), 10);
|
||||
|
||||
if (Number.isNaN(newValue)) {
|
||||
newValue = opts.default ?? 0;
|
||||
}
|
||||
|
||||
if (opts.min) {
|
||||
if (newValue < opts.min) {
|
||||
newValue = opts.min;
|
||||
}
|
||||
|
||||
if (opts.max && newValue > opts.max) {
|
||||
newValue = opts.max;
|
||||
}
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { paginate } from 'nestjs-typeorm-paginate';
|
||||
import type {
|
||||
IPaginationMeta,
|
||||
IPaginationOptions,
|
||||
} from 'nestjs-typeorm-paginate';
|
||||
import type { FindManyOptions, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export async function paginateHelper(
|
||||
queryBuilder: SelectQueryBuilder<any>,
|
||||
findOptions: FindManyOptions<any>,
|
||||
options: IPaginationOptions,
|
||||
) {
|
||||
const totalItems = await queryBuilder
|
||||
.clone()
|
||||
.setFindOptions(findOptions)
|
||||
.getCount();
|
||||
return await paginate(queryBuilder.setFindOptions(findOptions), {
|
||||
...options,
|
||||
countQueries: false,
|
||||
metaTransformer: (meta: IPaginationMeta): IPaginationMeta => {
|
||||
return {
|
||||
...meta,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / meta.itemsPerPage),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class CreateDataDto {
|
||||
id?: string;
|
||||
index: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class CreateIndexDto {
|
||||
index: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class DeleteBulkDataDto {
|
||||
ids: number[];
|
||||
index: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 { PaginationDto } from '@/common/dtos';
|
||||
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||
|
||||
export class GetDataDto extends PaginationDto {
|
||||
index: string;
|
||||
query: OsQueryDto;
|
||||
sort: string[];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { CreateIndexDto } from './create-index.dto';
|
||||
export { PutMappingsDto } from './put-mappings.dto';
|
||||
export { CreateDataDto } from './create-data.dto';
|
||||
export { GetDataDto } from './get-data.dto';
|
||||
export { UpdateDataDto } from './update-data.dto';
|
||||
export { DeleteBulkDataDto } from './delete-bulk-data.dto';
|
||||
export { ScrollDto } from './scroll.dto';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { Property } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||
|
||||
export class PutMappingsDto {
|
||||
index: string;
|
||||
mappings: Record<string, Property>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||
|
||||
export class ScrollDto {
|
||||
index: string;
|
||||
query: OsQueryDto;
|
||||
sort: string[];
|
||||
size: number;
|
||||
scrollId: string | null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class UpdateDataDto {
|
||||
id: string;
|
||||
index: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { OpensearchRepository } from './opensearch.repository';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class LargeWindowException extends BadRequestException {
|
||||
constructor(message: string) {
|
||||
super({
|
||||
code: ErrorCode.Opensearch.LargeWindow,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* 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 {
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { Client } from '@opensearch-project/opensearch';
|
||||
import type { TextProperty } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||
|
||||
import { getMockProvider } from '@/test-utils/util-functions';
|
||||
import { CreateDataDto, PutMappingsDto } from './dtos';
|
||||
import { OpensearchRepository } from './opensearch.repository';
|
||||
|
||||
const MockClient = {
|
||||
indices: {
|
||||
create: jest.fn(),
|
||||
putAlias: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
putMapping: jest.fn(),
|
||||
getMapping: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
index: jest.fn(),
|
||||
search: jest.fn(),
|
||||
scroll: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteByQuery: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const OpensearchRepositoryProviders = [
|
||||
OpensearchRepository,
|
||||
getMockProvider('OPENSEARCH_CLIENT', MockClient),
|
||||
];
|
||||
|
||||
const COMPLICATE_JSON = {
|
||||
KEY1: 'VALUE1',
|
||||
KEY2: 'VALUE2',
|
||||
};
|
||||
|
||||
const MAPPING_JSON = {
|
||||
KEY1: {
|
||||
type: 'text',
|
||||
} as TextProperty,
|
||||
};
|
||||
|
||||
describe('Opensearch Repository Test suite', () => {
|
||||
let osRepo: OpensearchRepository;
|
||||
let osClient: Client;
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: OpensearchRepositoryProviders,
|
||||
}).compile();
|
||||
osRepo = module.get(OpensearchRepository);
|
||||
osClient = module.get('OPENSEARCH_CLIENT');
|
||||
});
|
||||
|
||||
describe('create index', () => {
|
||||
it('positive case', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const indexName = 'channel_' + index;
|
||||
jest.spyOn(osClient.indices, 'create');
|
||||
jest.spyOn(osClient.indices, 'putAlias');
|
||||
|
||||
await osRepo.createIndex({ index });
|
||||
|
||||
expect(osClient.indices.create).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.create).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
body: {
|
||||
settings: {
|
||||
index: { max_ngram_diff: 1 },
|
||||
analysis: {
|
||||
analyzer: {
|
||||
ngram_analyzer: {
|
||||
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||
tokenizer: 'ngram_tokenizer',
|
||||
type: 'custom',
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
ngram_tokenizer: {
|
||||
type: 'ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 2,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(osClient.indices.putAlias).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putAlias).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
name: index,
|
||||
});
|
||||
});
|
||||
|
||||
it('creating index handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = new Error('Index creation failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.createIndex({ index })).rejects.toThrow(
|
||||
'Index creation failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('creating index handles OpenSearch specific errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = {
|
||||
meta: {
|
||||
body: {
|
||||
error: {
|
||||
type: 'resource_already_exists_exception',
|
||||
reason: 'index already exists',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.createIndex({ index })).rejects.toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putMappings', () => {
|
||||
it('putting mappings succeeds with an existent index', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'putMapping');
|
||||
|
||||
await osRepo.putMappings(dto);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).toHaveBeenCalledWith({
|
||||
index: dto.index,
|
||||
body: { properties: dto.mappings },
|
||||
});
|
||||
});
|
||||
it('putting mappings fails with a nonexistent index', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 404 } as never);
|
||||
jest.spyOn(osClient.indices, 'putMapping');
|
||||
|
||||
await expect(osRepo.putMappings(dto)).rejects.toThrow(
|
||||
new NotFoundException('index is not found'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('putting mappings handles OpenSearch errors', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
|
||||
const error = {
|
||||
meta: {
|
||||
body: {
|
||||
error: {
|
||||
type: 'illegal_argument_exception',
|
||||
reason: 'mapping update failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(osClient.indices, 'putMapping')
|
||||
.mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.putMappings(dto)).rejects.toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createData', () => {
|
||||
it('creating data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = index;
|
||||
dto.data = COMPLICATE_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + index]: {
|
||||
mappings: {
|
||||
properties: dto.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const response = await osRepo.createData(dto);
|
||||
|
||||
expect(response.id).toEqual(dto.id);
|
||||
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).toHaveBeenCalledWith({
|
||||
id: dto.id,
|
||||
index: 'channel_' + index,
|
||||
body: dto.data,
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
it('creating data fails with an invalid index', async () => {
|
||||
const invalidIndex = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = invalidIndex;
|
||||
dto.data = COMPLICATE_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ body: false } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + faker.number.int().toString()]: {
|
||||
mappings: {
|
||||
properties: dto.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||
new NotFoundException('index is not found'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.getMapping).not.toHaveBeenCalled();
|
||||
expect(osClient.index).not.toHaveBeenCalled();
|
||||
});
|
||||
it('creating data fails with invalid data', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const data = COMPLICATE_JSON;
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = index;
|
||||
dto.data = {
|
||||
...data,
|
||||
invalidKey: 'invalidValue',
|
||||
};
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + index]: {
|
||||
mappings: {
|
||||
properties: data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||
new InternalServerErrorException('error!!!'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getData', () => {
|
||||
it('getting data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort = ['_id:desc'];
|
||||
const limit = 10;
|
||||
const page = 1;
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [
|
||||
{ _source: { KEY1: 'VALUE1' } },
|
||||
{ _source: { KEY2: 'VALUE2' } },
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getData({ index, query, sort, limit, page });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
from: 0,
|
||||
size: limit,
|
||||
sort,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting data with empty sort adds default sort', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort: string[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [],
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.getData({ index, query, sort, page: 1, limit: 100 });
|
||||
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
from: 0,
|
||||
size: 100,
|
||||
sort: ['_id:desc'],
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting data handles large window exception', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
const error = new Error('Result window is too large');
|
||||
error.name = 'OpenSearchClientError';
|
||||
|
||||
jest.spyOn(osClient, 'search').mockRejectedValue(error as never);
|
||||
|
||||
await expect(
|
||||
osRepo.getData({ index, query, sort: [], page: 1, limit: 100 }),
|
||||
).rejects.toThrow('Result window is too large');
|
||||
});
|
||||
|
||||
it('getting data handles total as object', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [],
|
||||
total: { value: 100, relation: 'eq' },
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getData({
|
||||
index,
|
||||
query,
|
||||
sort: [],
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(result.total).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll', () => {
|
||||
it('scrolling with scrollId succeeds', async () => {
|
||||
const scrollId = faker.string.alphanumeric(32);
|
||||
const mockData = [{ KEY1: 'VALUE1' }, { KEY2: 'VALUE2' }];
|
||||
|
||||
jest.spyOn(osClient, 'scroll').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: mockData.map((data) => ({ _source: data })),
|
||||
},
|
||||
_scroll_id: scrollId,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.scroll({
|
||||
scrollId,
|
||||
index: '',
|
||||
size: 10,
|
||||
query: { bool: { must: [{ term: { status: 'active' } }] } },
|
||||
sort: [],
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(mockData);
|
||||
expect(result.scrollId).toEqual(scrollId);
|
||||
expect(osClient.scroll).toHaveBeenCalledWith({
|
||||
scroll_id: scrollId,
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolling without scrollId performs initial search', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort = ['_id:desc'];
|
||||
const size = 10;
|
||||
const mockData = [{ KEY1: 'VALUE1' }];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: mockData.map((data) => ({ _source: data })),
|
||||
},
|
||||
_scroll_id: 'new_scroll_id',
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.scroll({
|
||||
index,
|
||||
query,
|
||||
sort,
|
||||
size,
|
||||
scrollId: null,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(mockData);
|
||||
expect(result.scrollId).toEqual('new_scroll_id');
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
size,
|
||||
sort,
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolling with empty sort adds default sort', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort: string[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: { hits: [] },
|
||||
_scroll_id: 'scroll_id',
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.scroll({ index, query, sort, size: 10, scrollId: null });
|
||||
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
size: 10,
|
||||
sort: ['_id:desc'],
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateData', () => {
|
||||
it('updating data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||
|
||||
jest.spyOn(osClient, 'update').mockResolvedValue({
|
||||
body: {
|
||||
_id: id,
|
||||
result: 'updated',
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.updateData({ index, id, data: updateData });
|
||||
|
||||
expect(osClient.update).toHaveBeenCalledWith({
|
||||
index,
|
||||
id,
|
||||
body: {
|
||||
doc: updateData,
|
||||
},
|
||||
refresh: true,
|
||||
retry_on_conflict: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('updating data handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||
const error = new Error('Update failed');
|
||||
|
||||
jest.spyOn(osClient, 'update').mockRejectedValue(error as never);
|
||||
|
||||
await expect(
|
||||
osRepo.updateData({ index, id, data: updateData }),
|
||||
).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBulkData', () => {
|
||||
it('deleting bulk data succeeds with valid ids', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const ids = [faker.number.int(), faker.number.int()];
|
||||
|
||||
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||
body: {
|
||||
deleted: ids.length,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteBulkData({ index, ids });
|
||||
|
||||
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting bulk data with empty ids array', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const ids: number[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||
body: {
|
||||
deleted: 0,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteBulkData({ index, ids });
|
||||
|
||||
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIndex', () => {
|
||||
it('deleting index succeeds with valid index', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const indexName = 'channel_' + index;
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||
body: {
|
||||
acknowledged: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteIndex(index);
|
||||
|
||||
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting index handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = new Error('Delete failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.deleteIndex(index)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotal', () => {
|
||||
it('getting total count succeeds with valid query', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||
body: {
|
||||
count: 100,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getTotal(index, query);
|
||||
|
||||
expect(result).toBe(100);
|
||||
expect(osClient.count).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting total count with complex query', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { status: 'active' } },
|
||||
{ range: { created_at: { gte: '2023-01-01' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||
body: {
|
||||
count: 50,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getTotal(index, query);
|
||||
|
||||
expect(result).toBe(50);
|
||||
expect(osClient.count).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting total count handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const error = new Error('Count failed');
|
||||
|
||||
jest.spyOn(osClient, 'count').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.getTotal(index, query)).rejects.toThrow(
|
||||
'Count failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllIndexes', () => {
|
||||
it('deleting all indexes succeeds', async () => {
|
||||
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||
body: {
|
||||
acknowledged: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteAllIndexes();
|
||||
|
||||
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||
index: '_all',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting all indexes handles errors', async () => {
|
||||
const error = new Error('Delete all failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.deleteAllIndexes()).rejects.toThrow(
|
||||
'Delete all failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Client, errors } from '@opensearch-project/opensearch';
|
||||
import { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
|
||||
|
||||
import type {
|
||||
CreateDataDto,
|
||||
CreateIndexDto,
|
||||
DeleteBulkDataDto,
|
||||
GetDataDto,
|
||||
PutMappingsDto,
|
||||
ScrollDto,
|
||||
UpdateDataDto,
|
||||
} from './dtos';
|
||||
import { LargeWindowException } from './large-window.exception';
|
||||
|
||||
@Injectable()
|
||||
export class OpensearchRepository {
|
||||
private logger = new Logger(OpensearchRepository.name);
|
||||
private opensearchClient: Client;
|
||||
constructor(@Inject('OPENSEARCH_CLIENT') opensearchClient: Client) {
|
||||
this.opensearchClient = opensearchClient;
|
||||
}
|
||||
|
||||
async createIndex({ index }: CreateIndexDto) {
|
||||
const indexName = 'channel_' + index;
|
||||
try {
|
||||
const response = await this.opensearchClient.indices.create({
|
||||
index: indexName,
|
||||
body: {
|
||||
settings: {
|
||||
index: { max_ngram_diff: 1 },
|
||||
analysis: {
|
||||
analyzer: {
|
||||
ngram_analyzer: {
|
||||
type: 'custom',
|
||||
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||
tokenizer: 'ngram_tokenizer',
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
ngram_tokenizer: {
|
||||
type: 'ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 2,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (response) {
|
||||
this.logger.log(
|
||||
`Index created successfully: ${JSON.stringify(response.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.log(`Error creating index: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.log(
|
||||
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.opensearchClient.indices.putAlias({
|
||||
index: indexName,
|
||||
name: index,
|
||||
});
|
||||
}
|
||||
|
||||
async putMappings({ index, mappings }: PutMappingsDto) {
|
||||
const { statusCode } = await this.opensearchClient.indices.exists({
|
||||
index,
|
||||
});
|
||||
if (statusCode !== 200) throw new NotFoundException('index is not found');
|
||||
|
||||
let response: Indices_PutMapping_Response;
|
||||
try {
|
||||
response = await this.opensearchClient.indices.putMapping({
|
||||
index,
|
||||
body: { properties: mappings },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.log(`Error put mapping: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.log(
|
||||
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async createData({ id, index, data }: CreateDataDto) {
|
||||
const indexName = 'channel_' + index;
|
||||
const existence = await this.opensearchClient.indices.exists({
|
||||
index: indexName,
|
||||
});
|
||||
if (existence.statusCode !== 200)
|
||||
throw new NotFoundException('index is not found');
|
||||
|
||||
const response = await this.opensearchClient.indices.getMapping({
|
||||
index: indexName,
|
||||
});
|
||||
|
||||
const mappingKeys = Object.keys(
|
||||
response.body[indexName].mappings.properties as object,
|
||||
);
|
||||
const dataKeys = Object.keys(data);
|
||||
if (!dataKeys.every((v) => mappingKeys.includes(v))) {
|
||||
throw new InternalServerErrorException('error!!!');
|
||||
}
|
||||
|
||||
const { body } = await this.opensearchClient.index({
|
||||
id,
|
||||
index: indexName,
|
||||
body: data,
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
return { id: body._id as unknown as number };
|
||||
}
|
||||
|
||||
async getData(dto: GetDataDto) {
|
||||
const { index, limit = 100, page = 1, query, sort } = dto;
|
||||
|
||||
if (sort.length === 0) {
|
||||
sort.push('_id:desc');
|
||||
}
|
||||
try {
|
||||
const { body } = await this.opensearchClient.search({
|
||||
index,
|
||||
from: (page - 1) * limit,
|
||||
size: limit,
|
||||
sort,
|
||||
body: { query },
|
||||
});
|
||||
|
||||
return {
|
||||
items: body.hits.hits.map((v) => ({
|
||||
...v._source,
|
||||
})) as Record<string, any>[],
|
||||
total:
|
||||
typeof body.hits.total === 'number' ?
|
||||
body.hits.total
|
||||
: (body.hits.total?.value ?? 0),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof errors.OpenSearchClientError) {
|
||||
if (error.message.includes('Result window is too large')) {
|
||||
throw new LargeWindowException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async scroll(dto: ScrollDto) {
|
||||
const { index, size, scrollId, query, sort } = dto;
|
||||
|
||||
if (sort.length === 0) sort.push('_id:desc');
|
||||
|
||||
if (scrollId) {
|
||||
const { body } = await this.opensearchClient.scroll({
|
||||
scroll_id: scrollId,
|
||||
scroll: '1m',
|
||||
});
|
||||
return this.convertToScrollData(body);
|
||||
}
|
||||
|
||||
const { body } = await this.opensearchClient.search({
|
||||
index,
|
||||
size,
|
||||
sort,
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
return this.convertToScrollData(body);
|
||||
}
|
||||
|
||||
private convertToScrollData(body) {
|
||||
return {
|
||||
data: body.hits.hits.map((v) => ({
|
||||
...v._source,
|
||||
})) as Record<string, any>[],
|
||||
scrollId: body._scroll_id,
|
||||
};
|
||||
}
|
||||
|
||||
async updateData({ id, index, data }: UpdateDataDto) {
|
||||
try {
|
||||
await this.opensearchClient.update({
|
||||
id,
|
||||
index,
|
||||
body: { doc: data },
|
||||
refresh: true,
|
||||
retry_on_conflict: 5,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Error updating data: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.error(
|
||||
`OpenSearch error details: ${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBulkData({ ids, index }: DeleteBulkDataDto) {
|
||||
await this.opensearchClient.deleteByQuery({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteIndex(index: string) {
|
||||
await this.opensearchClient.indices.delete({ index: 'channel_' + index });
|
||||
}
|
||||
|
||||
async deleteAllIndexes() {
|
||||
await this.opensearchClient.indices.delete({ index: '_all' });
|
||||
}
|
||||
|
||||
async getTotal(index: string, query: object): Promise<number> {
|
||||
const { body } = await this.opensearchClient.count({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
|
||||
return body.count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ValidationArguments, ValidationOptions } from 'class-validator';
|
||||
import { registerDecorator } from 'class-validator';
|
||||
|
||||
export const ArrayDistinct = (
|
||||
property?: string,
|
||||
validationOptions?: ValidationOptions,
|
||||
) => {
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
name: 'ArrayDistinct',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
constraints: [property],
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(value: unknown): boolean {
|
||||
return Array.isArray(value) ?
|
||||
[...new Set(value)].length === value.length
|
||||
: false;
|
||||
},
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `must not contains duplicate entry for ${args.constraints[0]}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export { ArrayDistinct } from './array-distinct';
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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 { IsNotEmpty, validate } from 'class-validator';
|
||||
|
||||
import { TokenValidator } from './token-validator';
|
||||
|
||||
class TokenDto {
|
||||
@IsNotEmpty()
|
||||
@TokenValidator({ message: 'Invalid token format' })
|
||||
token: string;
|
||||
}
|
||||
|
||||
describe('TokenValidator', () => {
|
||||
it('should validate a correct token', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'validToken123456';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should invalidate a token with invalid characters', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'invalidToken$123';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||
});
|
||||
|
||||
it('should invalidate a token that is too short', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'short';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||
});
|
||||
|
||||
it('should invalidate an empty token', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = '';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 {
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ async: false })
|
||||
export class TokenValidatorConstraint implements ValidatorConstraintInterface {
|
||||
validate(token: string | null) {
|
||||
const regex = /^[a-zA-Z0-9._-]+$/;
|
||||
return (
|
||||
!token ||
|
||||
(typeof token === 'string' && regex.test(token) && token.length >= 16)
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return 'Token must be at least 16 characters long and contain only alphanumeric characters, dots, hyphens, and underscores.';
|
||||
}
|
||||
}
|
||||
|
||||
export function TokenValidator(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: TokenValidatorConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user