diff --git a/backend/console-server/src/clickhouse/clickhouse.ts b/backend/console-server/src/clickhouse/clickhouse.ts index 54cfd311..4e9c630b 100644 --- a/backend/console-server/src/clickhouse/clickhouse.ts +++ b/backend/console-server/src/clickhouse/clickhouse.ts @@ -87,7 +87,7 @@ export class Clickhouse implements OnModuleInit, OnModuleDestroy { return new Promise((resolve) => setTimeout(resolve, ms)); } - async query(query: string, params?: Record): Promise { + async query(query: string, params?: Record): Promise { try { const resultSet = await this.client.query({ query, diff --git a/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts b/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts index 7b1bc5d2..61ddc637 100644 --- a/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts +++ b/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts @@ -1,4 +1,6 @@ -import { MetricAggregationType, metricExpressions } from '../util/metric-expressions'; +import type { MetricAggregationType } from '../util/metric-expressions'; +import { metricExpressions } from '../util/metric-expressions'; +import { mapFilterCondition } from '../util/map-filter-condition'; interface metric { name: string; @@ -7,7 +9,8 @@ interface metric { export class TimeSeriesQueryBuilder { private query: string; - private params: Record = {}; + private params: Record = {}; + private limitValue?: number; constructor() { this.query = `SELECT`; @@ -59,12 +62,19 @@ export class TimeSeriesQueryBuilder { return this; } - filter(filters: Record): this { + filter(filters: Record): this { if (filters) { - Object.entries(filters).forEach(([key, value]) => { - this.query += ` AND ${key} = {${key}}`; - this.params[key] = value; + const conditions = Object.entries(filters).map(([key, value]) => { + const { condition, param } = mapFilterCondition(key, value); + this.params[key] = param; + return condition; }); + + if (this.query.includes('WHERE')) { + this.query += ` AND ${conditions.join(' AND ')}`; + } else { + this.query += ` WHERE ${conditions.join(' AND ')}`; + } } return this; @@ -88,7 +98,16 @@ export class TimeSeriesQueryBuilder { return this; } + limit(value: number): this { + this.limitValue = value; + return this; + } + build() { + if (this.limitValue) { + this.query += ` LIMIT ${this.limitValue}`; + } + console.log(this.query); return { query: this.query, params: this.params }; diff --git a/backend/console-server/src/clickhouse/util/map-filter-condition.ts b/backend/console-server/src/clickhouse/util/map-filter-condition.ts new file mode 100644 index 00000000..7ba3f109 --- /dev/null +++ b/backend/console-server/src/clickhouse/util/map-filter-condition.ts @@ -0,0 +1,19 @@ +type FilterValue = string | number | Date | unknown; + +export function mapFilterCondition( + key: string, + value: FilterValue, +): { condition: string; param: FilterValue } { + let type: string; + + if (typeof value === 'string') { + type = 'String'; + } else if (typeof value === 'number') { + type = Number.isInteger(value) ? 'Int32' : 'Float64'; + } else if (value instanceof Date) { + type = 'DateTime64(3)'; + } else { + throw new Error(`Unsupported filter value type for key "${key}": ${typeof value}`); + } + return { condition: `${key} = {${key}:${type}}`, param: value }; +} diff --git a/backend/console-server/src/clickhouse/util/metric-expressions.ts b/backend/console-server/src/clickhouse/util/metric-expressions.ts index e3017b65..0116aa5d 100644 --- a/backend/console-server/src/clickhouse/util/metric-expressions.ts +++ b/backend/console-server/src/clickhouse/util/metric-expressions.ts @@ -1,13 +1,13 @@ type MetricFunction = (metric: string) => string; export const metricExpressions: Record = { - avg: (metric: string) => `avg(${metric}) as ${metric}`, + avg: (metric: string) => `avg(${metric}) as avg_${metric}`, count: () => `count() as count`, - sum: (metric: string) => `sum(${metric}) as ${metric}`, - min: (metric: string) => `min(${metric}) as ${metric}`, - max: (metric: string) => `max(${metric}) as ${metric}`, - p95: (metric: string) => `quantile(0.95)(${metric}) as ${metric}`, - p99: (metric: string) => `quantile(0.99)(${metric}) as ${metric}`, + sum: (metric: string) => `sum(${metric}) as sum_${metric}`, + min: (metric: string) => `min(${metric}) as min_${metric}`, + max: (metric: string) => `max(${metric}) as max_${metric}`, + p95: (metric: string) => `quantile(0.95)(${metric}) as p95_${metric}`, + p99: (metric: string) => `quantile(0.99)(${metric}) as p99_${metric}`, rate: (metric: string) => `(sum(${metric}) / count(*)) * 100 as ${metric}_rate`, }; diff --git a/backend/console-server/src/log/dto/get-dau-by-project-response.dto.ts b/backend/console-server/src/log/dto/get-dau-by-project-response.dto.ts new file mode 100644 index 00000000..2a3b0883 --- /dev/null +++ b/backend/console-server/src/log/dto/get-dau-by-project-response.dto.ts @@ -0,0 +1,27 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +@Exclude() +export class GetDAUByProjectResponseDto { + @ApiProperty({ + example: 'my-project', + description: '프로젝트 이름', + }) + @Expose() + projectName: string; + + @ApiProperty({ + example: '2023-10-01', + description: '조회한 날짜', + }) + @Expose() + date: string; + + @ApiProperty({ + example: 12345, + description: '해당 날짜의 DAU 값', + }) + @Expose() + @Type(() => Number) + dau: number; +} diff --git a/backend/console-server/src/log/dto/get-dau-by-project.dto.ts b/backend/console-server/src/log/dto/get-dau-by-project.dto.ts new file mode 100644 index 00000000..dc62fc7d --- /dev/null +++ b/backend/console-server/src/log/dto/get-dau-by-project.dto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty, IsString, IsDateString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GetDAUByProjectDto { + @ApiProperty({ + example: 'watchducks', + description: '프로젝트 이름', + }) + @IsNotEmpty() + @IsString() + projectName: string; + + @ApiProperty({ + example: '2023-10-01', + description: '조회할 날짜 (YYYY-MM-DD 형식)', + }) + @IsNotEmpty() + @IsDateString() + date: string; +} diff --git a/backend/console-server/src/log/dto/get-path-speed-rank-response.dto.ts b/backend/console-server/src/log/dto/get-path-speed-rank-response.dto.ts new file mode 100644 index 00000000..470a492f --- /dev/null +++ b/backend/console-server/src/log/dto/get-path-speed-rank-response.dto.ts @@ -0,0 +1,44 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +class PathResponseDto { + @ApiProperty({ + example: '/api/v1/resource', + description: '사용자의 요청 경로', + }) + @Expose() + path: string; + + @ApiProperty({ + example: 123.45, + description: '해당 경로의 평균 응답 소요 시간 (ms).', + }) + @Expose() + avg_elapsed_time: number; +} + +@Exclude() +export class GetPathSpeedRankResponseDto { + @ApiProperty({ + example: 'watchducks', + description: '프로젝트 이름', + }) + @Expose() + projectName: string; + + @ApiProperty({ + type: [PathResponseDto], + description: '프로젝트의 가장 빠른 응답 경로 배열', + }) + @Expose() + @Type(() => PathResponseDto) + fastestPaths: Array; + + @ApiProperty({ + type: [PathResponseDto], + description: '프로젝트의 가장 느린 응답 경로 배열', + }) + @Expose() + @Type(() => PathResponseDto) + slowestPaths: Array; +} diff --git a/backend/console-server/src/log/dto/get-path-speed-rank.dto.ts b/backend/console-server/src/log/dto/get-path-speed-rank.dto.ts new file mode 100644 index 00000000..8eeef896 --- /dev/null +++ b/backend/console-server/src/log/dto/get-path-speed-rank.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GetPathSpeedRankDto { + @IsNotEmpty() + @IsString() + projectName: string; +} diff --git a/backend/console-server/src/log/dto/get-success-rate-by-project-response.dto.ts b/backend/console-server/src/log/dto/get-success-rate-by-project-response.dto.ts new file mode 100644 index 00000000..1bbd0e91 --- /dev/null +++ b/backend/console-server/src/log/dto/get-success-rate-by-project-response.dto.ts @@ -0,0 +1,18 @@ +import { Expose, Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GetSuccessRateByProjectResponseDTO { + @ApiProperty({ + description: '프로젝트의 이름', + example: 'watchducks', + }) + @Expose() + projectName: string; + @ApiProperty({ + description: '프로젝트의 응답 성공률', + example: 85.5, + }) + @Expose() + @Type(() => Number) + success_rate: number; +} diff --git a/backend/console-server/src/log/dto/get-success-rate-by-project.dto.ts b/backend/console-server/src/log/dto/get-success-rate-by-project.dto.ts new file mode 100644 index 00000000..f5f94d27 --- /dev/null +++ b/backend/console-server/src/log/dto/get-success-rate-by-project.dto.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GetSuccessRateByProjectDto { + @IsString() + @ApiProperty({ + description: '프로젝트 이름', + example: 'watchducks', + required: true, + }) + projectName: string; +} diff --git a/backend/console-server/src/log/dto/get-success-rate-response.dto.ts b/backend/console-server/src/log/dto/get-success-rate-response.dto.ts new file mode 100644 index 00000000..b40182c8 --- /dev/null +++ b/backend/console-server/src/log/dto/get-success-rate-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class GetSuccessRateResponseDto { + @ApiProperty({ + example: 95.5, + description: '응답 성공률 (%)', + type: Number, + }) + @Expose() + success_rate: number; +} diff --git a/backend/console-server/src/log/dto/get-success-rate.dto.ts b/backend/console-server/src/log/dto/get-success-rate.dto.ts new file mode 100644 index 00000000..a4735ee4 --- /dev/null +++ b/backend/console-server/src/log/dto/get-success-rate.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber } from 'class-validator'; + +export class GetSuccessRateDto { + @IsNumber() + @Type(() => Number) + @ApiProperty({ + description: '기수', + example: 5, + required: true, + }) + generation: number; +} diff --git a/backend/console-server/src/log/dto/get-traffic-by-generation-response.dto.ts b/backend/console-server/src/log/dto/get-traffic-by-generation-response.dto.ts new file mode 100644 index 00000000..71384188 --- /dev/null +++ b/backend/console-server/src/log/dto/get-traffic-by-generation-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class GetTrafficByGenerationResponseDto { + @ApiProperty({ + example: 15, + description: '기수 별 트래픽 수', + type: Number, + }) + @Expose() + count: number; +} diff --git a/backend/console-server/src/log/dto/get-traffic-by-generation.dto.ts b/backend/console-server/src/log/dto/get-traffic-by-generation.dto.ts new file mode 100644 index 00000000..4949fd75 --- /dev/null +++ b/backend/console-server/src/log/dto/get-traffic-by-generation.dto.ts @@ -0,0 +1,14 @@ +import { IsNumber } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GetTrafficByGenerationDto { + @IsNumber() + @Type(() => Number) + @ApiProperty({ + description: '기수', + example: 5, + required: true, + }) + generation: number; +} diff --git a/backend/console-server/src/log/dto/get-traffic-by-project-response.dto.ts b/backend/console-server/src/log/dto/get-traffic-by-project-response.dto.ts new file mode 100644 index 00000000..ec2862a1 --- /dev/null +++ b/backend/console-server/src/log/dto/get-traffic-by-project-response.dto.ts @@ -0,0 +1,43 @@ +import { Expose, Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +class TrafficDataPoint { + @ApiProperty({ + example: '2024-11-07 23:07:28', + description: '시간 단위 별 타임스탬프', + }) + @Expose() + timestamp: string; + + @ApiProperty({ + example: 1500, + description: '해당 타임스탬프의 트래픽 총량', + }) + @Expose() + @Type(() => Number) + count: number; +} + +export class GetTrafficByProjectResponseDto { + @ApiProperty({ + example: 'watchducks', + description: '프로젝트 이름', + }) + @Expose() + projectName: string; + + @ApiProperty({ + example: 'hour', + description: '시간 단위', + }) + @Expose() + timeUnit: string; + + @ApiProperty({ + type: [TrafficDataPoint], + description: '시간 단위 별 트래픽 데이터', + }) + @Expose() + @Type(() => TrafficDataPoint) + trafficData: TrafficDataPoint[]; +} diff --git a/backend/console-server/src/log/dto/get-traffic-by-project.dto.ts b/backend/console-server/src/log/dto/get-traffic-by-project.dto.ts new file mode 100644 index 00000000..fc16e6e9 --- /dev/null +++ b/backend/console-server/src/log/dto/get-traffic-by-project.dto.ts @@ -0,0 +1,30 @@ +import { IsNotEmpty, IsString, IsIn } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; + +export class GetTrafficByProjectDto { + @ApiProperty({ + example: 'watchducks', + description: '프로젝트 이름', + }) + @IsNotEmpty() + @IsString() + projectName: string; + + @ApiProperty({ + example: 'hour', + description: '시간 단위 (Minute, Hour, Day, Week, Month)', + enum: ['Minute', 'Hour', 'Day', 'Week', 'Month'], + }) + @IsNotEmpty() + @IsString() + @Transform(({ value }) => { + if (typeof value === 'string') { + const lower = value.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); + } + return value; + }) + @IsIn(['Minute', 'Hour', 'Day', 'Week', 'Month']) + timeUnit: string; +} diff --git a/backend/console-server/src/log/log.contorller.spec.ts b/backend/console-server/src/log/log.contorller.spec.ts new file mode 100644 index 00000000..2e265c22 --- /dev/null +++ b/backend/console-server/src/log/log.contorller.spec.ts @@ -0,0 +1,329 @@ +import { Test } from '@nestjs/testing'; +import { HttpStatus } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import { LogController } from './log.controller'; +import { LogService } from './log.service'; + +interface TrafficRankResponseType { + status: number; + data: Array<{ host: string; count: number }>; +} + +describe('LogController 테스트', () => { + let controller: LogController; + let service: LogService; + + const mockLogService = { + httpLog: jest.fn(), + elapsedTime: jest.fn(), + trafficRank: jest.fn(), + getResponseSuccessRate: jest.fn(), + getResponseSuccessRateByProject: jest.fn(), + getTrafficByGeneration: jest.fn(), + getPathSpeedRankByProject: jest.fn(), + getTrafficByProject: jest.fn(), + getDAUByProject: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { + provide: LogService, + useValue: mockLogService, + }, + ], + }).compile(); + + controller = module.get(LogController); + service = module.get(LogService); + + jest.clearAllMocks(); + }); + + it('컨트롤러가 정의되어 있어야 한다', () => { + expect(controller).toBeDefined(); + }); + + describe('httpLog는 ', () => { + const mockResult = [ + { + date: '2024-11-18', + avg_elapsed_time: 100, + request_count: 1000, + }, + ]; + + it('HTTP 로그 데이터를 반환해야 한다', async () => { + mockLogService.httpLog.mockResolvedValue(mockResult); + + const result = await controller.httpLog(); + + expect(result).toEqual(mockResult); + expect(service.httpLog).toHaveBeenCalledTimes(1); + }); + + it('서비스 에러 시 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockLogService.httpLog.mockRejectedValue(error); + + await expect(controller.httpLog()).rejects.toThrow(error); + }); + }); + + describe('elapsedTime()은 ', () => { + const mockResult = { + status: HttpStatus.OK, + data: { avg_elapsed_time: 150 }, + }; + + it('평균 응답 시간을 ProjectResponseDto 형식으로 반환해야 한다', async () => { + mockLogService.elapsedTime.mockResolvedValue(mockResult); + + const result = await controller.elapsedTime(); + + expect(result).toEqual(mockResult); + expect(result).toHaveProperty('status', HttpStatus.OK); + expect(result).toHaveProperty('data.avg_elapsed_time'); + expect(service.elapsedTime).toHaveBeenCalledTimes(1); + }); + }); + + describe('trafficRank()는 ', () => { + const mockResult: TrafficRankResponseType = { + status: HttpStatus.OK, + data: [ + { host: 'api1.example.com', count: 1000 }, + { host: 'api2.example.com', count: 800 }, + { host: 'api3.example.com', count: 600 }, + { host: 'api4.example.com', count: 400 }, + { host: 'api5.example.com', count: 200 }, + ], + }; + + it('TOP 5 트래픽 순위를 ProjectResponseDto 형식으로 반환해야 한다', async () => { + mockLogService.trafficRank.mockResolvedValue(mockResult); + + const result = (await controller.trafficRank()) as unknown as TrafficRankResponseType; + + expect(result).toEqual(mockResult); + expect(result).toHaveProperty('status', HttpStatus.OK); + expect(result.data).toHaveLength(5); + expect(service.trafficRank).toHaveBeenCalledTimes(1); + + const sortedData = [...result.data].sort((a, b) => b.count - a.count); + expect(result.data).toEqual(sortedData); + }); + }); + + describe('getResponseSuccessRate()는 ', () => { + const mockSuccessRateDto = { generation: 5 }; + const mockServiceResponse = { success_rate: 98.5 }; + + it('응답 성공률을 반환해야 한다', async () => { + mockLogService.getResponseSuccessRate.mockResolvedValue(mockServiceResponse); + + const result = await controller.getResponseSuccessRate(mockSuccessRateDto); + + expect(result).toEqual({ success_rate: 98.5 }); + expect(service.getResponseSuccessRate).toHaveBeenCalledWith(mockSuccessRateDto); + expect(service.getResponseSuccessRate).toHaveBeenCalledTimes(1); + }); + }); + + describe('getResponseSuccessRateByProject()는 ', () => { + const mockRequestDto = { projectName: 'example-project' }; + const mockServiceResponse = { + projectName: 'example-project', + success_rate: 95.5, + }; + + it('해당 프로젝트의 응답 성공률을 반환해야 한다', async () => { + mockLogService.getResponseSuccessRateByProject.mockResolvedValue(mockServiceResponse); + + const result = await controller.getResponseSuccessRateByProject(mockRequestDto); + + expect(result).toEqual(mockServiceResponse); + expect(result).toHaveProperty('projectName', 'example-project'); + expect(result).toHaveProperty('success_rate', 95.5); + expect(service.getResponseSuccessRateByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getResponseSuccessRateByProject).toHaveBeenCalledTimes(1); + }); + + it('서비스 호출 시, 에러가 발생하면 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockLogService.getResponseSuccessRateByProject.mockRejectedValue(error); + + await expect( + controller.getResponseSuccessRateByProject(mockRequestDto), + ).rejects.toThrow(error); + + expect(service.getResponseSuccessRateByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getResponseSuccessRateByProject).toHaveBeenCalledTimes(1); + }); + + it('성공률이 100%인 경우에도 정상적으로 반환해야 한다', async () => { + const perfectRateResponse = { + projectName: 'example-project', + success_rate: 100, + }; + mockLogService.getResponseSuccessRateByProject.mockResolvedValue(perfectRateResponse); + + const result = await controller.getResponseSuccessRateByProject(mockRequestDto); + + expect(result).toEqual(perfectRateResponse); + expect(result.success_rate).toBe(100); + expect(service.getResponseSuccessRateByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getResponseSuccessRateByProject).toHaveBeenCalledTimes(1); + }); + }); + + describe('trafficByGeneration()는 ', () => { + it('기수별 트래픽 총량을 올바르게 반환해야 한다', async () => { + const mockTrafficByGenerationDto = { generation: 9 }; + const mockResponse = { count: 1000 }; + + mockLogService.getTrafficByGeneration.mockResolvedValue(mockResponse); + + const result = await controller.getTrafficByGeneration(mockTrafficByGenerationDto); + + expect(result).toEqual(mockResponse); + expect(result).toHaveProperty('count', 1000); + expect(service.getTrafficByGeneration).toHaveBeenCalledWith(mockTrafficByGenerationDto); + expect(service.getTrafficByGeneration).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPathSpeedRankByProject()는 ', () => { + const mockRequestDto = { + projectName: 'example-project', + }; + + const mockResponseDto = { + projectName: 'example-project', + fastestPaths: [ + { path: '/api/v1/resource', avg_elapsed_time: 123.45 }, + { path: '/api/v1/users', avg_elapsed_time: 145.67 }, + { path: '/api/v1/orders', avg_elapsed_time: 150.89 }, + ], + slowestPaths: [ + { path: '/api/v1/reports', avg_elapsed_time: 345.67 }, + { path: '/api/v1/logs', avg_elapsed_time: 400.23 }, + { path: '/api/v1/stats', avg_elapsed_time: 450.56 }, + ], + }; + + it('프로젝트의 경로별 응답 속도 순위를 반환해야 한다', async () => { + mockLogService.getPathSpeedRankByProject.mockResolvedValue(mockResponseDto); + + const result = await controller.getPathSpeedRankByProject(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(service.getPathSpeedRankByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getPathSpeedRankByProject).toHaveBeenCalledTimes(1); + + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result.fastestPaths).toHaveLength(3); + expect(result.slowestPaths).toHaveLength(3); + }); + + it('서비스 에러 시 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockLogService.getPathSpeedRankByProject.mockRejectedValue(error); + + await expect(controller.getPathSpeedRankByProject(mockRequestDto)).rejects.toThrow( + error, + ); + + expect(service.getPathSpeedRankByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getPathSpeedRankByProject).toHaveBeenCalledTimes(1); + }); + }); + + describe('getTrafficByProject()는', () => { + const mockRequestDto = { projectName: 'example-project', timeUnit: 'month' }; + const mockResponseDto = { + projectName: 'example-project', + timeUnit: 'month', + trafficData: [ + { timestamp: '2024-11-01', count: 14 }, + { timestamp: '2024-10-01', count: 10 }, + ], + }; + it('프로젝트의 시간 단위별 트래픽 데이터를 반환해야 한다', async () => { + mockLogService.getTrafficByProject.mockResolvedValue(mockResponseDto); + + const result = await controller.getTrafficByProject(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result).toHaveProperty('timeUnit', mockRequestDto.timeUnit); + expect(result.trafficData).toHaveLength(2); + expect(result.trafficData[0]).toHaveProperty('timestamp', '2024-11-01'); + expect(result.trafficData[0]).toHaveProperty('count', 14); + expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); + }); + + it('서비스 에러 시 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockLogService.getTrafficByProject.mockRejectedValue(error); + + await expect(controller.getTrafficByProject(mockRequestDto)).rejects.toThrow(error); + + expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); + }); + + it('빈 트래픽 데이터를 반환해야 한다 (No Data)', async () => { + const emptyResponseDto = { + projectName: 'example-project', + timeUnit: 'month', + trafficData: [], + }; + mockLogService.getTrafficByProject.mockResolvedValue(emptyResponseDto); + + const result = await controller.getTrafficByProject(mockRequestDto); + + expect(result).toEqual(emptyResponseDto); + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result).toHaveProperty('timeUnit', mockRequestDto.timeUnit); + expect(result.trafficData).toHaveLength(0); + expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); + }); + }); + describe('getDAUByProject()는', () => { + const mockRequestDto = { projectName: 'example-project', date: '2024-11-01' }; + + const mockResponseDto = { + projectName: 'example-project', + date: '2024-11-01', + dau: 125, + }; + + it('프로젝트명과 날짜가 들어왔을 때 DAU 데이터를 반환해야 한다', async () => { + mockLogService.getDAUByProject.mockResolvedValue(mockResponseDto); + + const result = await controller.getDAUByProject(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result).toHaveProperty('date', mockRequestDto.date); + expect(result).toHaveProperty('dau', 125); + expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getDAUByProject).toHaveBeenCalledTimes(1); + }); + + it('서비스 에러 시 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockLogService.getDAUByProject.mockRejectedValue(error); + + await expect(controller.getDAUByProject(mockRequestDto)).rejects.toThrow(error); + + expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getDAUByProject).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/console-server/src/log/log.controller.ts b/backend/console-server/src/log/log.controller.ts index c84d82fe..68d80207 100644 --- a/backend/console-server/src/log/log.controller.ts +++ b/backend/console-server/src/log/log.controller.ts @@ -1,7 +1,18 @@ -import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; import { LogService } from './log.service'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ProjectResponseDto } from '../project/dto/create-project-response.dto'; +import { GetPathSpeedRankDto } from './dto/get-path-speed-rank.dto'; +import { GetPathSpeedRankResponseDto } from './dto/get-path-speed-rank-response.dto'; +import { GetTrafficByProjectResponseDto } from './dto/get-traffic-by-project-response.dto'; +import { GetTrafficByProjectDto } from './dto/get-traffic-by-project.dto'; +import { GetDAUByProjectResponseDto } from './dto/get-dau-by-project-response.dto'; +import { GetDAUByProjectDto } from './dto/get-dau-by-project.dto'; +import { GetSuccessRateResponseDto } from './dto/get-success-rate-response.dto'; +import { GetSuccessRateDto } from './dto/get-success-rate.dto'; +import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; +import { GetSuccessRateByProjectDto } from './dto/get-success-rate-by-project.dto'; + @Controller('log') export class LogController { @@ -43,7 +54,7 @@ export class LogController { return await this.logService.trafficRank(); } - @Get('/response-rate') + @Get('/success-rate') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '기수 내 응답 성공률', @@ -52,24 +63,86 @@ export class LogController { @ApiResponse({ status: 200, description: '기수 내 응답 성공률이 성공적으로 반환됨.', - type: ProjectResponseDto, + type: GetSuccessRateResponseDto, }) - async responseSuccessRate() { - return await this.logService.responseSuccessRate(); + async getResponseSuccessRate(getSuccessRateDto: GetSuccessRateDto) { + return await this.logService.getResponseSuccessRate(getSuccessRateDto); + } + + @Get('/success-rate/project') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '프로젝트 별 응답 성공률', + description: '요청받은 프로젝트의 응답 성공률을 성공적으로 반환합니다.', + }) + @ApiResponse({ + status: 200, + description: '프로젝트 별 응답 성공률이 성공적으로 반환됨.', + type: GetSuccessRateByProjectDto, + }) + async getResponseSuccessRateByProject( + @Query() getSuccessRateByProjectDto: GetSuccessRateByProjectDto, + ) { + return await this.logService.getResponseSuccessRateByProject(getSuccessRateByProjectDto); + } + + @Get('/traffic/project') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '프로젝트 별 트래픽 조회', + description: '프로젝트 이름과 시간 단위로 특정 프로젝트의 트래픽 데이터를 반환합니다.', + }) + @ApiResponse({ + status: 200, + description: '특정 프로젝트의 트래픽 데이터가 반환됨.', + type: GetTrafficByProjectResponseDto, + }) + async getTrafficByProject(@Query() getTrafficByProjectDto: GetTrafficByProjectDto) { + return await this.logService.getTrafficByProject(getTrafficByProjectDto); } @Get('/traffic') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '기수 내 총 트래픽', - description: '요청받은 기수의 기수 내 총 트래픽를 반환합니다.', + description: ' 요청받은 기수의 기수 내 총 트래픽를 반환합니다.', }) @ApiResponse({ status: 200, description: '기수 내 총 트래픽가 정상적으로 반환됨.', - type: ProjectResponseDto, + type: GetTrafficByProjectResponseDto, + }) + async getTrafficByGeneration(@Query() getTrafficByGenerationDto: GetTrafficByGenerationDto) { + return await this.logService.getTrafficByGeneration(getTrafficByGenerationDto); + } + + @Get('/elapsed-time/path-rank') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '개별 프로젝트의 경로별 응답 속도 순위', + description: '개별 프로젝트의 경로별 응답 속도 중 가장 빠른/느린 3개를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '개별 프로젝트의 경로별 응답 속도 중 가장 빠른/느린 3개가 반환됨.', + type: GetPathSpeedRankResponseDto, + }) + async getPathSpeedRankByProject(@Query() getPathSpeedRankDto: GetPathSpeedRankDto) { + return await this.logService.getPathSpeedRankByProject(getPathSpeedRankDto); + } + + @Get('/dau') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '프로젝트별 DAU 조회', + description: '프로젝트 이름과 날짜로 해당 프로젝트의 DAU를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '프로젝트의 DAU가 정상적으로 반환됨.', + type: GetDAUByProjectResponseDto, }) - async trafficByGeneration() { - return await this.logService.trafficByGeneration(); + async getDAUByProject(@Query() getDAUByProjectDto: GetDAUByProjectDto) { + return await this.logService.getDAUByProject(getDAUByProjectDto); } } diff --git a/backend/console-server/src/log/log.module.ts b/backend/console-server/src/log/log.module.ts index 566d8476..8a743c38 100644 --- a/backend/console-server/src/log/log.module.ts +++ b/backend/console-server/src/log/log.module.ts @@ -3,8 +3,11 @@ import { LogService } from './log.service'; import { LogController } from './log.controller'; import { LogRepository } from './log.repository'; import { Clickhouse } from '../clickhouse/clickhouse'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Project } from '../project/entities/project.entity'; @Module({ + imports: [TypeOrmModule.forFeature([Project])], controllers: [LogController], providers: [LogService, LogRepository, Clickhouse], }) diff --git a/backend/console-server/src/log/log.repository.spec.ts b/backend/console-server/src/log/log.repository.spec.ts new file mode 100644 index 00000000..181b35be --- /dev/null +++ b/backend/console-server/src/log/log.repository.spec.ts @@ -0,0 +1,349 @@ +import { Test } from '@nestjs/testing'; +import { LogRepository } from './log.repository'; +import { Clickhouse } from '../clickhouse/clickhouse'; +import type { TestingModule } from '@nestjs/testing'; + +describe('LogRepository 테스트', () => { + let repository: LogRepository; + let clickhouse: Clickhouse; + + const mockClickhouse = { + query: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LogRepository, + { + provide: Clickhouse, + useValue: mockClickhouse, + }, + ], + }).compile(); + + repository = module.get(LogRepository); + clickhouse = module.get(Clickhouse); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('레퍼지토리가 정의될 수 있어야 한다.', () => { + expect(repository).toBeDefined(); + }); + + describe('findHttpLog()는 ', () => { + it('요구 받은 조건의 트래픽 정보를 반환한다.', async () => { + const mockResult = [ + { + date: '2024-11-18', + avg_elapsed_time: 100, + request_count: 1000, + }, + ]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.findHttpLog(); + + expect(result).toEqual(mockResult); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching( + /SELECT.*toDate\(timestamp\).*FROM http_log.*GROUP BY date.*ORDER BY date/s, + ), + ); + }); + + it('결과가 없을 경우 빈 배열을 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue([]); + + const result = await repository.findHttpLog(); + + expect(result).toEqual([]); + }); + + it('clickhouse 에러 발생시 예외를 throw 해야 한다.', async () => { + const error = new Error('Clickhouse connection error'); + mockClickhouse.query.mockRejectedValue(error); + + await expect(repository.findHttpLog()).rejects.toThrow('Clickhouse connection error'); + }); + }); + + describe('findAvgElapsedTime()는 ', () => { + it('TimeSeriesQueryBuilder를 사용하여 올바른 쿼리를 생성해야 한다.', async () => { + const mockResult = [{ avg_elapsed_time: 150 }]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.findAvgElapsedTime(); + + expect(result).toEqual(mockResult[0]); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching(/SELECT.*avg\(elapsed_time\)/), + expect.any(Object), + ); + }); + }); + + describe('findCountByHost()는 ', () => { + it('호스트별 요청 수를 내림차순으로 정렬하여 반환해야 한다.', async () => { + const mockResult = [ + { host: 'api.example.com', count: 1000 }, + { host: 'web.example.com', count: 500 }, + ]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.findCountByHost(); + + expect(result).toEqual(mockResult); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching(/GROUP BY.*host.*ORDER BY.*DESC/), + expect.any(Object), + ); + }); + }); + + describe('findResponseSuccessRate()는 ', () => { + it('성공률을 올바르게 계산할 수 있어야 한다.', async () => { + const mockQueryResult = [{ is_error_rate: 1.5 }]; + (clickhouse.query as jest.Mock).mockResolvedValue(mockQueryResult); + + const result = await repository.findResponseSuccessRate(); + + expect(result).toEqual({ success_rate: 98.5 }); + expect(clickhouse.query).toHaveBeenCalledWith( + 'SELECT (sum(is_error) / count(*)) * 100 as is_error_rate\n FROM http_log', + ); + }); + + it('에러율이 0%일 때 성공률 100%를 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue([{ is_error_rate: 0 }]); + + const result = await repository.findResponseSuccessRate(); + + expect(result).toEqual({ success_rate: 100 }); + }); + }); + + describe('findResponseSuccessRateByProject()는 ', () => { + const domain = 'example.com'; + + it('프로젝트별 성공률을 올바르게 계산할 수 있어야 한다', async () => { + const mockQueryResult = [{ is_error_rate: 1.5 }]; + (clickhouse.query as jest.Mock).mockResolvedValue(mockQueryResult); + + const result = await repository.findResponseSuccessRateByProject(domain); + + const expectedQuery = `SELECT (sum(is_error) / count(*)) * 100 as is_error_rate + FROM (SELECT is_error, timestamp + FROM http_log WHERE host = {host:String} ORDER BY timestamp DESC LIMIT 1000) as subquery`; + + expect(result).toEqual({ success_rate: 98.5 }); + expect(clickhouse.query).toHaveBeenCalledWith( + expectedQuery, + expect.objectContaining({ host: domain }), + ); + }); + + it('에러율이 0%일 때 성공률 100%를 반환해야 한다', async () => { + (clickhouse.query as jest.Mock).mockResolvedValue([{ is_error_rate: 0 }]); + + const result = await repository.findResponseSuccessRateByProject(domain); + + expect(result).toEqual({ success_rate: 100 }); + }); + + it('쿼리 에러 발생시 예외를 throw 해야 한다', async () => { + const error = new Error('Clickhouse connection error'); + (clickhouse.query as jest.Mock).mockRejectedValue(error); + + await expect(repository.findResponseSuccessRateByProject(domain)).rejects.toThrow( + 'Clickhouse connection error', + ); + }); + }); + + describe('findTrafficByGeneration()는 ', () => { + it('전체 트래픽 수를 반환해야 한다.', async () => { + const mockResult = [{ count: 5000 }]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.findTrafficByGeneration(); + + expect(result).toEqual(mockResult); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching(/SELECT.*count\(\).*as count/s), + expect.any(Object), + ); + }); + }); + + describe('getPathSpeedRankByProject()는 ', () => { + const domain = 'example.com'; + const mockFastestPaths = [ + { path: '/api/v1/resource', avg_elapsed_time: 123.45 }, + { path: '/api/v1/users', avg_elapsed_time: 145.67 }, + { path: '/api/v1/orders', avg_elapsed_time: 150.89 }, + ]; + const mockSlowestPaths = [ + { path: '/api/v1/reports', avg_elapsed_time: 345.67 }, + { path: '/api/v1/logs', avg_elapsed_time: 400.23 }, + { path: '/api/v1/stats', avg_elapsed_time: 450.56 }, + ]; + + it('도메인을 기준으로 Top 3 fastest 경로와 slowest 경로를 반환해야 한다.', async () => { + mockClickhouse.query + .mockResolvedValueOnce(mockFastestPaths) + .mockResolvedValueOnce(mockSlowestPaths); + + const result = await repository.getPathSpeedRankByProject(domain); + + expect(result).toEqual({ + fastestPaths: mockFastestPaths, + slowestPaths: mockSlowestPaths, + }); + + expect(clickhouse.query).toHaveBeenCalledTimes(2); + expect(clickhouse.query).toHaveBeenNthCalledWith( + 1, + expect.stringMatching( + /SELECT\s+avg\(elapsed_time\) as avg_elapsed_time,\s+path\s+FROM http_log\s+WHERE host = \{host:String}\s+GROUP BY path\s+ORDER BY avg_elapsed_time\s+LIMIT 3/, + ), + expect.objectContaining({ host: domain }), + ); + expect(clickhouse.query).toHaveBeenNthCalledWith( + 2, + expect.stringMatching( + /SELECT\s+avg\(elapsed_time\) as avg_elapsed_time,\s+path\s+FROM http_log\s+WHERE host = \{host:String}\s+GROUP BY path\s+ORDER BY avg_elapsed_time DESC\s+LIMIT 3/, + ), + expect.objectContaining({ host: domain }), + ); + }); + + it('클릭하우스 에러 발생 시 예외를 throw 해야 한다.', async () => { + const error = new Error('Clickhouse query failed'); + mockClickhouse.query.mockRejectedValue(error); + + await expect(repository.getPathSpeedRankByProject(domain)).rejects.toThrow( + 'Clickhouse query failed', + ); + }); + }); + + describe('getTrafficByProject()는', () => { + const domain = 'example.com'; + const timeUnit = 'Hour'; + + const mockTrafficData = [ + { timestamp: '2024-11-18 10:00:00', count: 150 }, + { timestamp: '2024-11-18 11:00:00', count: 120 }, + { timestamp: '2024-11-18 12:00:00', count: 180 }, + ]; + + it('올바른 도메인과 시간 단위를 기준으로 트래픽 데이터를 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue(mockTrafficData); + + const result = await repository.getTrafficByProject(domain, timeUnit); + + expect(result).toEqual(mockTrafficData); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching( + /SELECT\s+count\(\)\s+as\s+count,\s+toStartOfHour\(timestamp\)\s+as\s+timestamp\s+FROM\s+http_log\s+WHERE\s+host\s+=\s+\{host:String}\s+GROUP\s+BY\s+timestamp\s+ORDER\s+BY\s+timestamp/s, + ), + expect.objectContaining({ host: domain }), + ); + }); + + it('트래픽 데이터가 없을 경우 빈 배열을 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue([]); + + const result = await repository.getTrafficByProject(domain, timeUnit); + + expect(result).toEqual([]); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ host: domain }), + ); + }); + + it('Clickhouse 호출 중 에러가 발생하면 예외를 throw 해야 한다.', async () => { + const error = new Error('Clickhouse query failed'); + mockClickhouse.query.mockRejectedValue(error); + + await expect(repository.getTrafficByProject(domain, timeUnit)).rejects.toThrow( + 'Clickhouse query failed', + ); + + expect(clickhouse.query).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ host: domain }), + ); + }); + }); + + describe('getDAUByProject()', () => { + const domain = 'example.com'; + const date = '2024-11-18'; + + it('존재하는 도메인과 날짜가 들어오면 존재하는 DAU 값을 반환해야 한다.', async () => { + const mockResult = [{ dau: 150 }]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.getDAUByProject(domain, date); + + expect(result).toBe(150); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching( + /SELECT.*SUM\(access\).*as dau.*FROM dau.*WHERE.*domain = \{domain:String}.*AND.*date = \{date:String}/s, + ), + expect.objectContaining({ domain, date }), + ); + }); + + it('DAU 데이터가 없을 경우 0을 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue([]); + + const result = await repository.getDAUByProject(domain, date); + + expect(result).toBe(0); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching( + /SELECT.*SUM\(access\).*as dau.*FROM dau.*WHERE.*domain = \{domain:String}.*AND.*date = \{date:String}/s, + ), + expect.objectContaining({ domain, date }), + ); + }); + + it('DAU 값이 null일 경우 0을 반환해야 한다.', async () => { + const mockResult = [{ dau: null }]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.getDAUByProject(domain, date); + + expect(result).toBe(0); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching( + /SELECT.*SUM\(access\).*as dau.*FROM dau.*WHERE.*domain = \{domain:String}.*AND.*date = \{date:String}/s, + ), + expect.objectContaining({ domain, date }), + ); + }); + + it('Clickhouse 호출 중 에러가 발생하면 예외를 throw 해야 한다.', async () => { + const error = new Error('Clickhouse query failed'); + mockClickhouse.query.mockRejectedValue(error); + + await expect(repository.getDAUByProject(domain, date)).rejects.toThrow( + 'Clickhouse query failed', + ); + + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching( + /SELECT.*SUM\(access\).*as dau.*FROM dau.*WHERE.*domain = \{domain:String}.*AND.*date = \{date:String}/s, + ), + expect.objectContaining({ domain, date }), + ); + }); + }); +}); diff --git a/backend/console-server/src/log/log.repository.ts b/backend/console-server/src/log/log.repository.ts index 3d9a1283..c1c987dc 100644 --- a/backend/console-server/src/log/log.repository.ts +++ b/backend/console-server/src/log/log.repository.ts @@ -15,7 +15,6 @@ export class LogRepository { AND timestamp < toStartOfDay(now()) GROUP BY date ORDER BY date;`; - return await this.clickhouse.query(sql); } @@ -50,7 +49,7 @@ export class LogRepository { } async findResponseSuccessRate() { - const { query, params } = new TimeSeriesQueryBuilder() + const { query } = new TimeSeriesQueryBuilder() .metrics([ { name: 'is_error', @@ -60,7 +59,32 @@ export class LogRepository { .from('http_log') .build(); - const result = await this.clickhouse.query(query, params); + const result = await this.clickhouse.query(query); + return { + success_rate: 100 - (result as Array<{ is_error_rate: number }>)[0].is_error_rate, + }; + } + + async findResponseSuccessRateByProject(domain: string) { + const subQueryBuilder = new TimeSeriesQueryBuilder() + .metrics([{ name: 'is_error' }, { name: 'timestamp' }]) + .from('http_log') + .filter({ host: domain }) + .orderBy(['timestamp'], true) + .limit(1000) + .build(); + + const mainQueryBuilder = new TimeSeriesQueryBuilder() + .metrics([ + { + name: 'is_error', + aggregation: 'rate', + }, + ]) + .from(`(${subQueryBuilder.query}) as subquery`) + .build(); + + const result = await this.clickhouse.query(mainQueryBuilder.query, subQueryBuilder.params); return { success_rate: 100 - (result as Array<{ is_error_rate: number }>)[0].is_error_rate, }; @@ -78,6 +102,65 @@ export class LogRepository { .build(); const result = await this.clickhouse.query(query, params); - return result[0]; + return [{ count: ((result as unknown[])[0] as { count: number }).count }]; + } + + async getPathSpeedRankByProject(domain: string) { + const fastestQueryBuilder = new TimeSeriesQueryBuilder() + .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'path' }]) + .from('http_log') + .filter({ host: domain }) + .groupBy(['path']) + .orderBy(['avg_elapsed_time'], false) + .limit(3) + .build(); + + const slowestQueryBuilder = new TimeSeriesQueryBuilder() + .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'path' }]) + .from('http_log') + .filter({ host: domain }) + .groupBy(['path']) + .orderBy(['avg_elapsed_time'], true) + .limit(3) + .build(); + + const [fastestPaths, slowestPaths] = await Promise.all([ + this.clickhouse.query(fastestQueryBuilder.query, fastestQueryBuilder.params), + this.clickhouse.query(slowestQueryBuilder.query, slowestQueryBuilder.params), + ]); + + return { + fastestPaths, + slowestPaths, + }; + } + + async getTrafficByProject(domain: string, timeUnit: string) { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([ + { name: '*', aggregation: 'count' }, + { name: `toStartOf${timeUnit}(timestamp) as timestamp` }, + ]) + .from('http_log') + .filter({ host: domain }) + .groupBy(['timestamp']) + .orderBy(['timestamp'], false) + .build(); + + return await this.clickhouse.query(query, params); + } + + async getDAUByProject(domain: string, date: string) { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([{ name: `SUM(access) as dau` }]) + .from('dau') + .filter({ domain: domain, date: date }) + .build(); + const result = await this.clickhouse.query<{ dau: number }>(query, params); + if (result.length > 0 && result[0].dau !== null) { + return result[0].dau; + } else { + return 0; + } } } diff --git a/backend/console-server/src/log/log.service.spec.ts b/backend/console-server/src/log/log.service.spec.ts new file mode 100644 index 00000000..6c2f5c85 --- /dev/null +++ b/backend/console-server/src/log/log.service.spec.ts @@ -0,0 +1,515 @@ +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { LogService } from './log.service'; +import { LogRepository } from './log.repository'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Project } from '../project/entities/project.entity'; +import { NotFoundException } from '@nestjs/common'; +import type { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generation-response.dto'; +import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; +import { GetSuccessRateByProjectResponseDTO } from './dto/get-success-rate-by-project-response.dto'; + +describe('LogService 테스트', () => { + let service: LogService; + let repository: LogRepository; + + const mockLogRepository = { + findHttpLog: jest.fn(), + findAvgElapsedTime: jest.fn(), + findCountByHost: jest.fn(), + findResponseSuccessRate: jest.fn(), + findResponseSuccessRateByProject: jest.fn(), + findTrafficByGeneration: jest.fn(), + getPathSpeedRankByProject: jest.fn(), + getTrafficByProject: jest.fn(), + getDAUByProject: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LogService, + { + provide: LogRepository, + useValue: mockLogRepository, + }, + { + provide: getRepositoryToken(Project), + useValue: { findOne: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(LogService); + repository = module.get(LogRepository); + + jest.clearAllMocks(); + }); + + it('서비스가 정의될 수 있어야 한다.', () => { + expect(service).toBeDefined(); + }); + + describe('httpLog()는 ', () => { + it('레포지토리에서 로그를 올바르게 반환할 수 있어야 있다.', async () => { + const mockLogs = [{ date: '2024-03-01', avg_elapsed_time: 100, request_count: 1000 }]; + mockLogRepository.findHttpLog.mockResolvedValue(mockLogs); + + const result = await service.httpLog(); + + expect(result).toEqual(mockLogs); + expect(repository.findHttpLog).toHaveBeenCalled(); + }); + + it('빈 결과를 잘 처리할 수 있어야 한다.', async () => { + mockLogRepository.findHttpLog.mockResolvedValue([]); + + const result = await service.httpLog(); + + expect(result).toEqual([]); + }); + + it('레포지토리 에러를 처리할 수 있어야 한다.', async () => { + mockLogRepository.findHttpLog.mockRejectedValue(new Error('Database error')); + + await expect(service.httpLog()).rejects.toThrow('Database error'); + }); + }); + + describe('elapsedTime()는 ', () => { + it('평균 응답 시간을 반환할 수 있어야 한다.', async () => { + const mockTime = { avg_elapsed_time: 150 }; + mockLogRepository.findAvgElapsedTime.mockResolvedValue(mockTime); + + const result = await service.elapsedTime(); + + expect(result).toEqual(mockTime); + expect(repository.findAvgElapsedTime).toHaveBeenCalled(); + }); + }); + + describe('trafficRank()는 ', () => { + it('top 5 traffic ranks를 리턴할 수 있어야 한다.', async () => { + const mockRanks = [ + { host: 'api1.example.com', count: 1000 }, + { host: 'api2.example.com', count: 800 }, + { host: 'api3.example.com', count: 600 }, + { host: 'api4.example.com', count: 400 }, + { host: 'api5.example.com', count: 200 }, + { host: 'api6.example.com', count: 110 }, + ]; + mockLogRepository.findCountByHost.mockResolvedValue(mockRanks); + + const result = await service.trafficRank(); + + expect(result).toHaveLength(5); + expect(result).toEqual(mockRanks.slice(0, 5)); + expect(repository.findCountByHost).toHaveBeenCalled(); + }); + + it('5개 이하의 결과에 대해서 올바르게 처리할 수 있어야 한다.', async () => { + const mockRanks = [ + { host: 'api1.example.com', count: 1000 }, + { host: 'api2.example.com', count: 800 }, + ]; + mockLogRepository.findCountByHost.mockResolvedValue(mockRanks); + + const result = await service.trafficRank(); + + expect(result).toHaveLength(2); + expect(result).toEqual(mockRanks); + }); + }); + + describe('responseSuccessRate()는 ', () => { + it('응답 성공률을 반환할 수 있어야 한다.', async () => { + const mockSuccessRateDto = { generation: 9 }; + const mockRepositoryResponse = { success_rate: 98.5 }; + mockLogRepository.findResponseSuccessRate.mockResolvedValue(mockRepositoryResponse); + const expectedResult = { success_rate: 98.5 }; + + const result = await service.getResponseSuccessRate(mockSuccessRateDto); + + expect(result).toEqual(expectedResult); + expect(repository.findResponseSuccessRate).toHaveBeenCalled(); + }); + }); + + describe('getResponseSuccessRateByProject()는 ', () => { + const mockRequestDto = { projectName: 'example-project' }; + const mockProject = { + name: 'example-project', + domain: 'example.com', + }; + const mockSuccessRate = { success_rate: 95.5 }; + + it('projectName을 이용해 도메인을 조회한 후 응답 성공률을 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.findResponseSuccessRateByProject = jest + .fn() + .mockResolvedValue(mockSuccessRate); + + const result = await service.getResponseSuccessRateByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.findResponseSuccessRateByProject).toHaveBeenCalledWith( + mockProject.domain, + ); + expect(result).toEqual( + expect.objectContaining({ + success_rate: mockSuccessRate.success_rate, + }), + ); + }); + + it('응답이 GetSuccessRateByProjectResponseDTO 형태로 변환되어야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.findResponseSuccessRateByProject.mockResolvedValue(mockSuccessRate); + + const result = await service.getResponseSuccessRateByProject(mockRequestDto); + + expect(result).toBeInstanceOf(GetSuccessRateByProjectResponseDTO); + expect(Object.keys(result)).toContain('projectName'); + expect(Object.keys(result)).toContain('success_rate'); + expect(Object.keys(result).length).toBe(2); + expect(result.projectName).toBe(mockRequestDto.projectName); + expect(result.success_rate).toBe(mockSuccessRate.success_rate); + }); + + it('존재하지 않는 프로젝트명을 받은 경우, NotFoundException을 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect(service.getResponseSuccessRateByProject(mockRequestDto)).rejects.toThrow( + new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.findResponseSuccessRateByProject).not.toHaveBeenCalled(); + }); + + it('레포지토리 호출 시, 에러가 발생하면, 예외를 throw 해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.findResponseSuccessRateByProject = jest + .fn() + .mockRejectedValue(new Error('Database error')); + + await expect(service.getResponseSuccessRateByProject(mockRequestDto)).rejects.toThrow( + 'Database error', + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.findResponseSuccessRateByProject).toHaveBeenCalledWith( + mockProject.domain, + ); + }); + }); + + describe('trafficByGeneration()는 ', () => { + it('기수별 트래픽의 총합을 올바르게 반환할 수 있어야 한다.', async () => { + const mockStats = [ + { generation: '10s', count: 500 }, + { generation: '20s', count: 300 }, + { generation: '30s', count: 200 }, + ]; + const expectedTotalCount = mockStats.reduce((sum, stat) => sum + stat.count, 0); + const dto = new GetTrafficByGenerationDto(); + dto.generation = 9; + + const mockRepositoryResponse = [{ count: expectedTotalCount }]; + const expectedResponse: GetTrafficByGenerationResponseDto = { + count: expectedTotalCount, + }; + mockLogRepository.findTrafficByGeneration.mockResolvedValue(mockRepositoryResponse); + + const result = await service.getTrafficByGeneration(dto); + + expect(result).toEqual(expectedResponse); + expect(mockLogRepository.findTrafficByGeneration).toHaveBeenCalledTimes(1); + expect(mockLogRepository.findTrafficByGeneration).toHaveBeenCalled(); + }); + }); + + describe('getPathSpeedRankByProject()는 ', () => { + const mockRequestDto = { projectName: 'example-project' }; + + const mockProject = { + name: 'example-project', + domain: 'example.com', + }; + + const mockPathSpeedRank = { + fastestPaths: [ + { path: '/api/v1/resource', avg_elapsed_time: 123.45 }, + { path: '/api/v1/users', avg_elapsed_time: 145.67 }, + { path: '/api/v1/orders', avg_elapsed_time: 150.89 }, + ], + slowestPaths: [ + { path: '/api/v1/reports', avg_elapsed_time: 345.67 }, + { path: '/api/v1/logs', avg_elapsed_time: 400.23 }, + { path: '/api/v1/stats', avg_elapsed_time: 450.56 }, + ], + }; + + it('프로젝트명을 기준으로 도메인을 조회한 후 경로별 응답 속도 순위를 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getPathSpeedRankByProject = jest + .fn() + .mockResolvedValue(mockPathSpeedRank); + + const result = await service.getPathSpeedRankByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getPathSpeedRankByProject).toHaveBeenCalledWith( + mockProject.domain, + ); + expect(result).toEqual({ + projectName: mockRequestDto.projectName, + ...mockPathSpeedRank, + }); + }); + + it('존재하지 않는 프로젝트명을 조회할 경우 NotFoundException을 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect(service.getPathSpeedRankByProject(mockRequestDto)).rejects.toThrow( + new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getPathSpeedRankByProject).not.toHaveBeenCalled(); + }); + + it('로그 레포지토리 호출 중 에러가 발생할 경우 예외를 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getPathSpeedRankByProject = jest + .fn() + .mockRejectedValue(new Error('Database error')); + + await expect(service.getPathSpeedRankByProject(mockRequestDto)).rejects.toThrow( + 'Database error', + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getPathSpeedRankByProject).toHaveBeenCalledWith( + mockProject.domain, + ); + }); + }); + + describe('getTrafficByProject()는', () => { + const mockRequestDto = { projectName: 'example-project', timeUnit: 'month' }; + const mockProject = { + name: 'example-project', + domain: 'example.com', + }; + const mockTrafficData = [ + { timestamp: '2024-11-01', count: 14 }, + { timestamp: '2024-10-01', count: 10 }, + ]; + const mockResponseDto = { + projectName: 'example-project', + timeUnit: 'month', + trafficData: mockTrafficData, + }; + + it('프로젝트명을 기준으로 도메인을 조회한 후 트래픽 데이터를 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getTrafficByProject = jest.fn().mockResolvedValue(mockTrafficData); + + const result = await service.getTrafficByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getTrafficByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.timeUnit, + ); + expect(result).toEqual(mockResponseDto); + }); + + it('존재하지 않는 프로젝트명을 조회할 경우 NotFoundException을 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect(service.getTrafficByProject(mockRequestDto)).rejects.toThrow( + new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getTrafficByProject).not.toHaveBeenCalled(); + }); + + it('로그 레포지토리 호출 중 에러가 발생할 경우 예외를 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getTrafficByProject = jest + .fn() + .mockRejectedValue(new Error('Database error')); + + await expect(service.getTrafficByProject(mockRequestDto)).rejects.toThrow( + 'Database error', + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getTrafficByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.timeUnit, + ); + }); + + it('트래픽 데이터가 없을 경우 빈 배열을 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getTrafficByProject = jest.fn().mockResolvedValue([]); + + const result = await service.getTrafficByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getTrafficByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.timeUnit, + ); + expect(result).toEqual({ + projectName: mockRequestDto.projectName, + timeUnit: mockRequestDto.timeUnit, + trafficData: [], + }); + }); + }); + + describe('getDAUByProject()는', () => { + const mockRequestDto = { projectName: 'example-project', date: '2024-11-01' }; + const mockProject = { + name: 'example-project', + domain: 'example.com', + }; + const mockDAUData = 125; + const mockResponseDto = { + projectName: 'example-project', + date: '2024-11-01', + dau: 125, + }; + + it('프로젝트명으로 도메인을 조회한 후 날짜에 따른 DAU 데이터를 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getDAUByProject = jest.fn().mockResolvedValue(mockDAUData); + + const result = await service.getDAUByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getDAUByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.date, + ); + expect(result).toEqual(mockResponseDto); + }); + + it('존재하지 않는 프로젝트명을 조회할 경우 NotFoundException을 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect(service.getDAUByProject(mockRequestDto)).rejects.toThrow( + new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getDAUByProject).not.toHaveBeenCalled(); + }); + + it('존재하는 프로젝트에 DAU 데이터가 없을 경우 0으로 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getDAUByProject = jest.fn().mockResolvedValue(0); + + const result = await service.getDAUByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getDAUByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.date, + ); + expect(result).toEqual({ + projectName: mockRequestDto.projectName, + date: mockRequestDto.date, + dau: 0, + }); + }); + + it('로그 레포지토리 호출 중 에러가 발생할 경우 예외를 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockLogRepository.getDAUByProject = jest + .fn() + .mockRejectedValue(new Error('Database error')); + + await expect(service.getDAUByProject(mockRequestDto)).rejects.toThrow('Database error'); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockLogRepository.getDAUByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.date, + ); + }); + }); +}); diff --git a/backend/console-server/src/log/log.service.ts b/backend/console-server/src/log/log.service.ts index bdc81a6a..c0383cd2 100644 --- a/backend/console-server/src/log/log.service.ts +++ b/backend/console-server/src/log/log.service.ts @@ -1,9 +1,28 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Project } from '../project/entities/project.entity'; +import { Repository } from 'typeorm'; import { LogRepository } from './log.repository'; +import { GetPathSpeedRankDto } from './dto/get-path-speed-rank.dto'; +import { GetPathSpeedRankResponseDto } from './dto/get-path-speed-rank-response.dto'; +import { GetTrafficByProjectDto } from './dto/get-traffic-by-project.dto'; +import { GetTrafficByProjectResponseDto } from './dto/get-traffic-by-project-response.dto'; +import { GetDAUByProjectDto } from './dto/get-dau-by-project.dto'; +import { GetDAUByProjectResponseDto } from './dto/get-dau-by-project-response.dto'; +import { GetSuccessRateResponseDto } from './dto/get-success-rate-response.dto'; +import { GetSuccessRateDto } from './dto/get-success-rate.dto'; +import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; +import { GetSuccessRateByProjectResponseDTO } from './dto/get-success-rate-by-project-response.dto'; +import { GetSuccessRateByProjectDto } from './dto/get-success-rate-by-project.dto'; @Injectable() export class LogService { - constructor(private readonly logRepository: LogRepository) {} + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + private readonly logRepository: LogRepository, + ) {} async httpLog() { const result = await this.logRepository.findHttpLog(); @@ -24,18 +43,86 @@ export class LogService { async trafficRank() { const result = await this.logRepository.findCountByHost(); - return result.slice(0, 4); + return result.slice(0, 5); } - async responseSuccessRate() { + async getResponseSuccessRate(_getSuccessRateDto: GetSuccessRateDto) { const result = await this.logRepository.findResponseSuccessRate(); - return result; + return plainToInstance(GetSuccessRateResponseDto, result); } - async trafficByGeneration() { + async getResponseSuccessRateByProject(getSuccessRateByProjectDto: GetSuccessRateByProjectDto) { + const { projectName } = getSuccessRateByProjectDto; + + const project = await this.projectRepository.findOne({ + where: { name: projectName }, + select: ['domain'], + }); + + if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); + + const result = await this.logRepository.findResponseSuccessRateByProject(project.domain); + + return plainToInstance(GetSuccessRateByProjectResponseDTO, { + projectName, + ...result, + }); + } + + async getTrafficByGeneration(_getTrafficByGenerationDto: GetTrafficByGenerationDto) { const result = await this.logRepository.findTrafficByGeneration(); - return result; + return plainToInstance(GetTrafficByGenerationDto, result[0]); + } + + async getPathSpeedRankByProject(getPathSpeedRankDto: GetPathSpeedRankDto) { + const { projectName } = getPathSpeedRankDto; + + const project = await this.projectRepository.findOne({ + where: { name: projectName }, + select: ['domain'], + }); + if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); + + const result = await this.logRepository.getPathSpeedRankByProject(project.domain); + + return plainToInstance(GetPathSpeedRankResponseDto, { projectName, ...result }); + } + + async getTrafficByProject(getTrafficByProjectDto: GetTrafficByProjectDto) { + const { projectName, timeUnit } = getTrafficByProjectDto; + + const project = await this.projectRepository.findOne({ + where: { name: projectName }, + select: ['domain'], + }); + if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); + + const trafficData = await this.logRepository.getTrafficByProject(project.domain, timeUnit); + + return plainToInstance(GetTrafficByProjectResponseDto, { + projectName, + timeUnit, + trafficData, + }); + } + + async getDAUByProject(getDAUByProjectDto: GetDAUByProjectDto) { + const { projectName, date } = getDAUByProjectDto; + + const project = await this.projectRepository.findOne({ + where: { name: projectName }, + select: ['domain'], + }); + if (!project) { + throw new NotFoundException(`Project with name ${projectName} not found`); + } + const dau = await this.logRepository.getDAUByProject(project.domain, date); + return plainToInstance(GetDAUByProjectResponseDto, { + projectName, + date, + dau, + }); } } diff --git a/backend/console-server/src/project/dto/create-project-response.dto.ts b/backend/console-server/src/project/dto/create-project-response.dto.ts index c080ae62..86f9c457 100644 --- a/backend/console-server/src/project/dto/create-project-response.dto.ts +++ b/backend/console-server/src/project/dto/create-project-response.dto.ts @@ -6,7 +6,6 @@ export class ProjectResponseDto { @ApiProperty({ example: '1', }) - @Expose() id: number; } diff --git a/backend/console-server/src/project/project.controller.spec.ts b/backend/console-server/src/project/project.controller.spec.ts index ee1fd6a3..8e534e40 100644 --- a/backend/console-server/src/project/project.controller.spec.ts +++ b/backend/console-server/src/project/project.controller.spec.ts @@ -31,6 +31,7 @@ describe('ProjectController의', () => { email: 'test@test.com', ip: '127.0.0.1', domain: 'host.test.com', + generation: 9, }; it('올바른 프로젝트 정보가 들어왔을 때 서비스의 create() 메소드를 호출해 프로젝트를 생성합니다.', async () => { diff --git a/backend/console-server/src/project/project.service.spec.ts b/backend/console-server/src/project/project.service.spec.ts index 921d1ec7..9b6f90c5 100644 --- a/backend/console-server/src/project/project.service.spec.ts +++ b/backend/console-server/src/project/project.service.spec.ts @@ -50,8 +50,15 @@ describe('ProjectService 클래스의', () => { email: 'test@test.com', ip: '127.0.0.1', domain: 'host.test.com', + generation: 9, }; + jest.setTimeout(30000); + beforeEach(() => { + jest.clearAllMocks(); + (mailService.sendNameServerInfo as jest.Mock).mockResolvedValue(undefined); + }); + it('올바른 정보가 들어왔을 때 프로젝트를 성공적으로 생성합니다.', async () => { const projectEntity = { id: 1, ...createProjectDto }; diff --git a/backend/name-server/package-lock.json b/backend/name-server/package-lock.json index a502bbfa..12ad45d2 100644 --- a/backend/name-server/package-lock.json +++ b/backend/name-server/package-lock.json @@ -8,6 +8,7 @@ "name": "name-server", "version": "1.0.0", "dependencies": { + "@clickhouse/client": "^1.8.1", "@types/better-sqlite3": "^7.6.11", "@types/dns-packet": "^5.6.5", "better-sqlite3": "^11.5.0", @@ -1923,6 +1924,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@clickhouse/client": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.8.1.tgz", + "integrity": "sha512-Ec0pCdwftIPD7hCxhOukHS0Zxr2tDc5mNAHBqkT3c0c6GO2WQdZkME9+EcfGcoF7+foUp82F5a0bPfSDDjfWmg==", + "license": "Apache-2.0", + "dependencies": { + "@clickhouse/client-common": "1.8.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@clickhouse/client-common": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-1.8.1.tgz", + "integrity": "sha512-Z0R5zKaS3N35Op338WVRHIfoqDh9gotXZwekm0lbHQmwNaj3nY2iJ113dFYKjb1V+ESu+PvLEA//LJUGZyPQOg==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", diff --git a/backend/name-server/package.json b/backend/name-server/package.json index f132214b..952986d0 100644 --- a/backend/name-server/package.json +++ b/backend/name-server/package.json @@ -43,6 +43,7 @@ "typescript": "^5.6.3" }, "dependencies": { + "@clickhouse/client": "^1.8.1", "@types/better-sqlite3": "^7.6.11", "@types/dns-packet": "^5.6.5", "better-sqlite3": "^11.5.0", diff --git a/backend/name-server/src/app.ts b/backend/name-server/src/app.ts index 73697edb..12e95354 100644 --- a/backend/name-server/src/app.ts +++ b/backend/name-server/src/app.ts @@ -5,6 +5,7 @@ import { Server } from './server/server'; import { db } from './database/mysql/mysql-database'; import { logger } from './common/utils/logger/console.logger'; import { ProjectQuery } from './database/query/project.query'; +import { DAURecorder } from 'database/query/dau-recorder'; config(); @@ -15,9 +16,10 @@ export class Application { await this.initializeDatabase(); const config = await this.initializeConfig(); + const dauRecorder = new DAURecorder(); const projectQuery = new ProjectQuery(); - return new Server(config, projectQuery); + return new Server(config, dauRecorder, projectQuery); } public async cleanup(): Promise { @@ -36,6 +38,6 @@ export class Application { private async initializeDatabase(): Promise { await db.connect(); - logger.info('Database connection established'); + logger.info('MySql Database connection established'); } } diff --git a/backend/name-server/src/database/clickhouse/clickhouse-database.ts b/backend/name-server/src/database/clickhouse/clickhouse-database.ts new file mode 100644 index 00000000..692a19da --- /dev/null +++ b/backend/name-server/src/database/clickhouse/clickhouse-database.ts @@ -0,0 +1,14 @@ +import { createClient } from '@clickhouse/client'; +import type { ClickHouseClient } from '@clickhouse/client'; +import { clickhouseConfig } from './config/clickhouse.config'; + +export class ClickhouseDatabase { + private static instance: ClickHouseClient; + + public static getInstance(): ClickHouseClient { + if (!ClickhouseDatabase.instance) { + ClickhouseDatabase.instance = createClient(clickhouseConfig); + } + return ClickhouseDatabase.instance; + } +} diff --git a/backend/name-server/src/database/clickhouse/config/clickhouse.config.ts b/backend/name-server/src/database/clickhouse/config/clickhouse.config.ts new file mode 100644 index 00000000..4406760a --- /dev/null +++ b/backend/name-server/src/database/clickhouse/config/clickhouse.config.ts @@ -0,0 +1,8 @@ +import { ClickHouseClientConfigOptions } from '@clickhouse/client'; + +export const clickhouseConfig: ClickHouseClientConfigOptions = { + url: process.env.CLICKHOUSE_URL || 'http://localhost:8123', + username: process.env.CLICKHOUSE_USERNAME || 'default', + password: process.env.CLICKHOUSE_PASSWORD || '', + database: process.env.CLICKHOUSE_DATABASE, +}; diff --git a/backend/name-server/src/database/query/dau-recorder.ts b/backend/name-server/src/database/query/dau-recorder.ts new file mode 100644 index 00000000..e5f1c27e --- /dev/null +++ b/backend/name-server/src/database/query/dau-recorder.ts @@ -0,0 +1,22 @@ +import { ClickhouseDatabase } from '../clickhouse/clickhouse-database'; + +export interface DAURecorderInterface { + recordAccess(domain: string): Promise; +} + +export class DAURecorder implements DAURecorderInterface { + private clickhouseClient = ClickhouseDatabase.getInstance(); + public async recordAccess(domain: string): Promise { + const date = new Date().toISOString().slice(0, 10); + const values = [{ domain, date, access: 1 }]; + try { + await this.clickhouseClient.insert({ + table: 'dau', + values, + format: 'JSONEachRow', + }); + } catch (error) { + console.error('ClickHouse Error:', error); + } + } +} diff --git a/backend/name-server/src/database/query/project.query.ts b/backend/name-server/src/database/query/project.query.ts index f6129f76..7d5222ca 100644 --- a/backend/name-server/src/database/query/project.query.ts +++ b/backend/name-server/src/database/query/project.query.ts @@ -1,6 +1,6 @@ import { db } from '../mysql/mysql-database'; import type { RowDataPacket } from 'mysql2/promise'; -import { ProjectQueryInterface } from './project.query.interface'; +import type { ProjectQueryInterface } from './project.query.interface'; interface ProjectExists extends RowDataPacket { exists_flag: number; diff --git a/backend/name-server/src/server/server.ts b/backend/name-server/src/server/server.ts index 414c80f3..d2d0806f 100644 --- a/backend/name-server/src/server/server.ts +++ b/backend/name-server/src/server/server.ts @@ -8,13 +8,15 @@ import { DNSResponseBuilder } from './utils/dns-response-builder'; import { ResponseCode } from './constant/dns-packet.constant'; import { logger } from '../common/utils/logger/console.logger'; import { ServerError } from './error/server.error'; -import { ProjectQueryInterface } from '../database/query/project.query.interface'; +import type { ProjectQueryInterface } from '../database/query/project.query.interface'; +import type { DAURecorderInterface } from 'database/query/dau-recorder'; export class Server { private server: Socket; constructor( private readonly config: ServerConfig, + private readonly dauRecorder: DAURecorderInterface, private readonly projectQuery: ProjectQueryInterface, ) { this.server = createSocket('udp4'); @@ -31,9 +33,11 @@ export class Server { try { const query = decode(msg); const question = this.parseQuery(query); - logger.logQuery(question.name, remoteInfo); await this.validateRequest(question.name); + this.dauRecorder.recordAccess(question.name).catch((err) => { + logger.error(`DAU recording failed for ${question.name}: ${err.message}`); + }); const response = new DNSResponseBuilder(this.config, query) .addAnswer(ResponseCode.NOERROR, question) diff --git a/backend/name-server/src/server/utils/dns-response-builder.ts b/backend/name-server/src/server/utils/dns-response-builder.ts index 33662374..42e4a945 100644 --- a/backend/name-server/src/server/utils/dns-response-builder.ts +++ b/backend/name-server/src/server/utils/dns-response-builder.ts @@ -52,7 +52,7 @@ export class DNSResponseBuilder { name: question.name, type: 'A', class: 'IN', - ttl: 10, + ttl: 86400, data: this.config.proxyServerIp, }, ]; diff --git a/backend/proxy-server/package-lock.json b/backend/proxy-server/package-lock.json index 2553cc81..ffb1bbae 100644 --- a/backend/proxy-server/package-lock.json +++ b/backend/proxy-server/package-lock.json @@ -108,16 +108,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", @@ -179,16 +169,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", @@ -211,16 +191,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", @@ -239,20 +209,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", "dev": true, "license": "MIT", "dependencies": { @@ -1786,16 +1746,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -1900,21 +1850,21 @@ "license": "MIT" }, "node_modules/@clickhouse/client": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.8.0.tgz", - "integrity": "sha512-IJ+/r3Wbg2t67sEtTA9dUgP2HjyGuze7ksNqeDfb7Ahnws1dzSVP2kg1Y9xOrb2e37K29JWWr26cX+rOiESm1A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.8.1.tgz", + "integrity": "sha512-Ec0pCdwftIPD7hCxhOukHS0Zxr2tDc5mNAHBqkT3c0c6GO2WQdZkME9+EcfGcoF7+foUp82F5a0bPfSDDjfWmg==", "license": "Apache-2.0", "dependencies": { - "@clickhouse/client-common": "1.8.0" + "@clickhouse/client-common": "1.8.1" }, "engines": { "node": ">=16" } }, "node_modules/@clickhouse/client-common": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-1.8.0.tgz", - "integrity": "sha512-aQgH0UODGuFHfL8rgeLSrGCoh3NCoNUs0tFGl0o79iyfASfvWtT/K/X3RM0QJpXXOgXpB//T2nD5XvCFtdk32w==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-1.8.1.tgz", + "integrity": "sha512-Z0R5zKaS3N35Op338WVRHIfoqDh9gotXZwekm0lbHQmwNaj3nY2iJ113dFYKjb1V+ESu+PvLEA//LJUGZyPQOg==", "license": "Apache-2.0" }, "node_modules/@esbuild/darwin-arm64": { @@ -1964,9 +1914,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", - "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.0.tgz", + "integrity": "sha512-zdHg2FPIFNKPdcHWtiNT+jEFCHYVplAXRDlQDyqy0zGx/q2parwh7brGJSiTxRk/TSMkbM//zt/f5CHgyTyaSQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1979,9 +1929,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", - "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.0.tgz", + "integrity": "sha512-7ATR9F0e4W85D/0w7cU0SNj7qkAexMG+bAHEZOjo9akvGuhHE2m7umzWzfnpa0XAg5Kxc1BWmtPMV67jJ+9VUg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1989,9 +1939,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", - "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", "dev": true, "license": "MIT", "dependencies": { @@ -2012,23 +1962,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -2042,17 +1975,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", - "integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.15.0.tgz", + "integrity": "sha512-tMTqrY+EzbXmKJR5ToI8lxu7jaN5EdmrBFJpQk5JmSlyLsx6o4t27r883K5xsLuCYCpfKBCGswMSWXsM+jB7lg==", "dev": true, "license": "MIT", "engines": { @@ -2070,9 +1996,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz", - "integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz", + "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2093,6 +2019,28 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.0.0.tgz", @@ -2118,16 +2066,16 @@ } }, "node_modules/@fastify/reply-from": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-11.0.1.tgz", - "integrity": "sha512-F2Qk88gcqIIiug9V+4I6WeeV1faj1Wu798JyOnwbJcjQhm4LYrHdkpFSVwJE0g1cVjYCFFmH3OVh1HHaninttQ==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-11.0.2.tgz", + "integrity": "sha512-VcHhe01PsHuVX2eVrkoskCs+pwNPgVfOOpwQJnSo3AwIKtISm0VCFB7bycQjHfxAuPYgkrI6ZvYoovdHx4sVMA==", "license": "MIT", "dependencies": { "@fastify/error": "^4.0.0", "end-of-stream": "^1.4.4", "fast-content-type-parse": "^2.0.0", "fast-querystring": "^1.1.2", - "fastify-plugin": "^4.5.1", + "fastify-plugin": "^5.0.1", "toad-cache": "^3.7.0", "undici": "^6.11.1" } @@ -2453,6 +2401,19 @@ "node": ">=10" } }, + "node_modules/@jest/reporters/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -2840,21 +2801,6 @@ "form-data": "^4.0.0" } }, - "node_modules/@types/superagent/node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@types/supertest": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", @@ -2884,17 +2830,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.13.0.tgz", - "integrity": "sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz", + "integrity": "sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.13.0", - "@typescript-eslint/type-utils": "8.13.0", - "@typescript-eslint/utils": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0", + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/type-utils": "8.15.0", + "@typescript-eslint/utils": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2918,16 +2864,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.13.0.tgz", - "integrity": "sha512-w0xp+xGg8u/nONcGw1UXAr6cjCPU1w0XVyBs6Zqaj5eLmxkKQAByTdV/uGgNN5tVvN/kKpoQlP2cL7R+ajZZIQ==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.15.0.tgz", + "integrity": "sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.13.0", - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/typescript-estree": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0", + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "debug": "^4.3.4" }, "engines": { @@ -2947,14 +2893,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.13.0.tgz", - "integrity": "sha512-XsGWww0odcUT0gJoBZ1DeulY1+jkaHUciUq4jKNv4cpInbvvrtDoyBH9rE/n2V29wQJPk8iCH1wipra9BhmiMA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz", + "integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0" + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2965,14 +2911,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.13.0.tgz", - "integrity": "sha512-Rqnn6xXTR316fP4D2pohZenJnp+NwQ1mo7/JM+J1LWZENSLkJI8ID8QNtlvFeb0HnFSK94D6q0cnMX6SbE5/vA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.15.0.tgz", + "integrity": "sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.13.0", - "@typescript-eslint/utils": "8.13.0", + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/utils": "8.15.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2983,6 +2929,9 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -2990,9 +2939,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.13.0.tgz", - "integrity": "sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz", + "integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==", "dev": true, "license": "MIT", "engines": { @@ -3004,14 +2953,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.13.0.tgz", - "integrity": "sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz", + "integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -3058,17 +3007,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.13.0.tgz", - "integrity": "sha512-A1EeYOND6Uv250nybnLZapeXpYMl8tkzYUxqmoKAWnI4sei3ihf2XdZVd+vVOmHGcp3t+P7yRrNsyyiXTvShFQ==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz", + "integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.13.0", - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/typescript-estree": "8.13.0" + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3079,17 +3041,22 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.13.0.tgz", - "integrity": "sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz", + "integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.15.0", + "eslint-visitor-keys": "^4.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3099,6 +3066,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -3129,15 +3109,15 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", @@ -3161,6 +3141,28 @@ } } }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3370,30 +3372,20 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", - "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.2", + "@babel/helper-define-polyfill-provider": "^0.6.3", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.10.6", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", @@ -3409,13 +3401,13 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", - "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2" + "@babel/helper-define-polyfill-provider": "^0.6.3" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3621,9 +3613,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001678", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001678.tgz", - "integrity": "sha512-RR+4U/05gNtps58PEBDZcPWTgEO2MBeoPZ96aQcjmfkBWRIDfN451fW2qyDA9/+HohLLIL5GqiMwA+IB1pWarw==", + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "dev": true, "funding": [ { @@ -3907,9 +3899,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -4099,9 +4091,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.52", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.52.tgz", - "integrity": "sha512-xtoijJTZ+qeucLBDNztDOuQBE1ksqjvNjvqFoST3nGC7fSpqJ+X6BdTBaY5BHG+IhWWmpc6b/KfpeuEDupEPOQ==", + "version": "1.5.63", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz", + "integrity": "sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA==", "dev": true, "license": "ISC" }, @@ -4229,27 +4221,27 @@ } }, "node_modules/eslint": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", - "integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.15.0.tgz", + "integrity": "sha512-7CrWySmIibCgT1Os28lUU6upBshZ+GxybLOrmRzi08kS8MBuO8QA7pXEgYgY5W8vK3e74xv0lpjo9DbaGU9Rkw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.18.0", - "@eslint/core": "^0.7.0", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.14.0", - "@eslint/plugin-kit": "^0.2.0", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.15.0", + "@eslint/plugin-kit": "^0.2.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.0", + "@humanwhocodes/retry": "^0.4.1", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.5", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", @@ -4268,8 +4260,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -4363,23 +4354,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", @@ -4410,13 +4384,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4681,12 +4648,40 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "license": "BSD-3-Clause" + }, "node_modules/fast-json-stringify/node_modules/fast-uri": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", "license": "MIT" }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -4758,11 +4753,23 @@ } }, "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.0.1.tgz", + "integrity": "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==", "license": "MIT" }, + "node_modules/fastify/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4884,9 +4891,9 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "dev": true, "license": "ISC" }, @@ -4900,17 +4907,17 @@ } }, "node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", + "combined-stream": "^1.0.8", "mime-types": "^2.1.12" }, "engines": { - "node": ">= 0.12" + "node": ">= 6" } }, "node_modules/formidable": { @@ -5175,28 +5182,6 @@ "node": ">=6" } }, - "node_modules/har-validator/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/har-validator/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5557,16 +5542,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -6112,6 +6087,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -6282,9 +6270,9 @@ } }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -6502,6 +6490,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -7135,7 +7136,7 @@ "punycode": "^2.3.1" } }, - "node_modules/punycode": { + "node_modules/psl/node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", @@ -7144,6 +7145,12 @@ "node": ">=6" } }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -7352,6 +7359,20 @@ "node": ">= 4" } }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7532,15 +7553,13 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/seq-queue": { @@ -7840,24 +7859,10 @@ "node": ">=14.18.0" } }, - "node_modules/superagent/node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/superagent/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.6" @@ -7940,13 +7945,6 @@ "node": ">=8" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -8004,12 +8002,6 @@ "node": ">=0.8" } }, - "node_modules/tough-cookie/node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", @@ -8072,6 +8064,19 @@ } } }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tsc-alias": { "version": "1.8.10", "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.10.tgz", @@ -8192,9 +8197,9 @@ } }, "node_modules/undici": { - "version": "6.20.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.20.1.tgz", - "integrity": "sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", + "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", "license": "MIT", "engines": { "node": ">=18.17" @@ -8291,6 +8296,15 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",