From 00b558f6c783d66a6edab750dc9156e23ae46334 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 01:36:53 +0900 Subject: [PATCH 01/16] =?UTF-8?q?=E2=9C=A8=20feat:=20stock=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A3=BC=EA=B8=B0=EB=B3=84=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/domain/stockData.entity.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 packages/backend/src/stock/domain/stockData.entity.ts diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts new file mode 100644 index 0000000..82882dd --- /dev/null +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -0,0 +1,161 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToOne, + JoinColumn, +} from 'typeorm'; +import { Stock } from './stock.entity'; + +@Entity('stock_minutely') +export class StockMinutely { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + close: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + low: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + high: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + open: number; + + @Column() + volume: number; + + @Column({ type: 'timestamp' }) + startTime: Date; + + @OneToOne(() => Stock) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @CreateDateColumn() + createdAt: Date; +} + +@Entity('stock_daily') +export class StockDaily { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + close: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + low: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + high: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + open: number; + + @Column() + volume: number; + + @Column({ type: 'timestamp' }) + startTime: Date; + + @OneToOne(() => Stock) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @CreateDateColumn() + createdAt: Date; +} +@Entity('stock_weekly') +export class StockWeekly { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + close: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + low: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + high: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + open: number; + + @Column() + volume: number; + + @Column({ type: 'timestamp' }) + startTime: Date; + + @OneToOne(() => Stock) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @CreateDateColumn() + createdAt: Date; +} +@Entity('stock_monthly') +export class StockMonthly { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + close: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + low: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + high: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + open: number; + + @Column() + volume: number; + + @Column({ type: 'timestamp' }) + startTime: Date; + + @OneToOne(() => Stock) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @CreateDateColumn() + createdAt: Date; +} +@Entity('stock_yearly') +export class StockYearly { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + close: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + low: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + high: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + open: number; + + @Column() + volume: number; + + @Column({ type: 'timestamp' }) + startTime: Date; + + @OneToOne(() => Stock) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @CreateDateColumn() + createdAt: Date; +} From c0d885bdf200bf82249a0667e4b71145015144ff Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 16:59:43 +0900 Subject: [PATCH 02/16] =?UTF-8?q?=E2=9C=A8=20feat:=20stock=20Data=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/dto/stockData.response.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/backend/src/stock/dto/stockData.response.ts diff --git a/packages/backend/src/stock/dto/stockData.response.ts b/packages/backend/src/stock/dto/stockData.response.ts new file mode 100644 index 0000000..9dcda92 --- /dev/null +++ b/packages/backend/src/stock/dto/stockData.response.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class PriceDto { + startTime: Date; + open: number; + high: number; + low: number; + close: number; +} + +export class VolumeDto { + startTime: Date; + volume: number; + color: string; +} + +export class StockDataResponse { + @ApiProperty({ + description: '가격 데이터 배열 (날짜 오름차순)', + }) + @Type(() => PriceDto) + priceDtoList: PriceDto[]; + + @ApiProperty({ + description: '거래량 데이터 배열 (날짜 오름차순, 색 포함)', + }) + @Type(() => VolumeDto) + volumeDtoList: VolumeDto[]; + + @ApiProperty({ + description: '스크롤해서 불러올 수 있는 데이터가 더 존재하는지', + }) + hasMore: boolean; +} From a5106ad2627375a9f906961000b26eef8f3460cf Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 17:02:59 +0900 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20stock=20Data=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/domain/stockData.entity.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts index 82882dd..62fd9ec 100644 --- a/packages/backend/src/stock/domain/stockData.entity.ts +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -25,17 +25,17 @@ export class StockMinutely { @Column({ type: 'decimal', precision: 15, scale: 2 }) open: number; - @Column() + @Column({ type: 'bigint' }) volume: number; - @Column({ type: 'timestamp' }) + @Column({ type: 'timestamp', name: 'start_time' }) startTime: Date; @OneToOne(() => Stock) @JoinColumn({ name: 'stock_id' }) stock: Stock; - @CreateDateColumn() + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } @@ -56,17 +56,17 @@ export class StockDaily { @Column({ type: 'decimal', precision: 15, scale: 2 }) open: number; - @Column() + @Column({ type: 'bigint' }) volume: number; - @Column({ type: 'timestamp' }) + @Column({ type: 'timestamp', name: 'start_time' }) startTime: Date; @OneToOne(() => Stock) @JoinColumn({ name: 'stock_id' }) stock: Stock; - @CreateDateColumn() + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } @Entity('stock_weekly') @@ -86,17 +86,17 @@ export class StockWeekly { @Column({ type: 'decimal', precision: 15, scale: 2 }) open: number; - @Column() + @Column({ type: 'bigint' }) volume: number; - @Column({ type: 'timestamp' }) + @Column({ type: 'timestamp', name: 'start_time' }) startTime: Date; @OneToOne(() => Stock) @JoinColumn({ name: 'stock_id' }) stock: Stock; - @CreateDateColumn() + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } @Entity('stock_monthly') @@ -116,17 +116,17 @@ export class StockMonthly { @Column({ type: 'decimal', precision: 15, scale: 2 }) open: number; - @Column() + @Column({ type: 'bigint' }) volume: number; - @Column({ type: 'timestamp' }) + @Column({ type: 'timestamp', name: 'start_time' }) startTime: Date; @OneToOne(() => Stock) @JoinColumn({ name: 'stock_id' }) stock: Stock; - @CreateDateColumn() + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } @Entity('stock_yearly') @@ -146,16 +146,16 @@ export class StockYearly { @Column({ type: 'decimal', precision: 15, scale: 2 }) open: number; - @Column() + @Column({ type: 'bigint' }) volume: number; - @Column({ type: 'timestamp' }) + @Column({ type: 'timestamp', name: 'start_time' }) startTime: Date; @OneToOne(() => Stock) @JoinColumn({ name: 'stock_id' }) stock: Stock; - @CreateDateColumn() + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } From 04d96790e9e6068a14ebe2d7a6bd91bab54a51e5 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 19:49:30 +0900 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20volume=20DTO=20colo?= =?UTF-8?q?r=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/dto/stockData.response.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/backend/src/stock/dto/stockData.response.ts b/packages/backend/src/stock/dto/stockData.response.ts index 9dcda92..520a202 100644 --- a/packages/backend/src/stock/dto/stockData.response.ts +++ b/packages/backend/src/stock/dto/stockData.response.ts @@ -12,7 +12,6 @@ export class PriceDto { export class VolumeDto { startTime: Date; volume: number; - color: string; } export class StockDataResponse { From 3a359429a415dccb54acb7621a6c481f234d8015 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 19:50:25 +0900 Subject: [PATCH 05/16] =?UTF-8?q?=E2=9C=A8=20feat:=20stockDataService=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stockData.service.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 packages/backend/src/stock/stockData.service.ts diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts new file mode 100644 index 0000000..7fa0703 --- /dev/null +++ b/packages/backend/src/stock/stockData.service.ts @@ -0,0 +1,198 @@ +import { Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { DataSource } from 'typeorm'; +import { Stock } from './domain/stock.entity'; +import { + StockMinutely, + StockDaily, + StockMonthly, + StockWeekly, + StockYearly, +} from './domain/stockData.entity'; +import { + PriceDto, + StockDataResponse, + VolumeDto, +} from './dto/stockData.response'; + +type StockData = { + id: number; + close: number; + low: number; + high: number; + open: number; + volume: number; + startTime: Date; + stock: Stock; + createdAt: Date; +}; + +@Injectable() +export class StockDataService { + protected readonly PAGE_SIZE = 100; + protected readonly DEFAULT_COLOR = 'red'; + + constructor(private readonly dataSource: DataSource) {} + + async getPaginated( + entity: new () => StockData, + stock_id: string, + lastStartTime?: string, + ): Promise { + return await this.dataSource.manager.transaction(async (manager) => { + const queryBuilder = manager + .createQueryBuilder(entity, 'entity') + .leftJoinAndSelect('entity.stock', 'stock') + .where('entity.stock_id = :stockId', { stockId: stock_id }) + .orderBy('entity.startTime', 'DESC') + .take(this.PAGE_SIZE + 1); + + if (lastStartTime) + queryBuilder.andWhere('entity.startTime < :lastStartTime', { + lastStartTime: lastStartTime, + }); + + const resultList = await queryBuilder.getMany(); + + const hasMore = resultList.length > this.PAGE_SIZE; + if (hasMore) resultList.pop(); + const priceDtoList = this.mapResultListToPriceDtoList(resultList); + const volumeDtoList = this.mapResultListToVolumeDtoList(resultList); + + return this.createStockDataResponse(priceDtoList, volumeDtoList, hasMore); + }); + } + + mapResultListToPriceDtoList(resultList: StockData[]): PriceDto[] { + return resultList + .map((data: StockData) => ({ + startTime: data.startTime, + open: data.open, + close: data.close, + high: data.high, + low: data.low, + })) + .reverse(); + } + + mapResultListToVolumeDtoList(resultList: StockData[]): VolumeDto[] { + return resultList + .map((data) => ({ + startTime: data.startTime, + volume: data.volume, + })) + .reverse(); + } + + createStockDataResponse( + priceDtoList: PriceDto[], + volumeDtoList: VolumeDto[], + hasMore: boolean, + ): StockDataResponse { + const priceData = plainToInstance(PriceDto, priceDtoList); + const volumeData = plainToInstance(VolumeDto, volumeDtoList); + + const responseDto = plainToInstance(StockDataResponse, { + priceDtoList: priceData, + volumeDtoList: volumeData, + hasMore, + }); + + return responseDto; + } +} + +@Injectable() +export class StockDataMinutelyService extends StockDataService { + constructor(dataSource: DataSource) { + super(dataSource); + } + async getStockDataMinutely( + stock_id: string, + lastStartTime?: string, + ): Promise { + const response = await this.getPaginated( + StockMinutely, + stock_id, + lastStartTime, + ); + + return response; + } +} + +@Injectable() +export class StockDataDailyService extends StockDataService { + constructor(dataSource: DataSource) { + super(dataSource); + } + async getStockDataDaily( + stock_id: string, + lastStartTime?: string, + ): Promise { + const response = await this.getPaginated( + StockDaily, + stock_id, + lastStartTime, + ); + + return response; + } +} + +@Injectable() +export class StockDataWeeklyService extends StockDataService { + constructor(dataSource: DataSource) { + super(dataSource); + } + async getStockDataWeekly( + stock_id: string, + lastStartTime?: string, + ): Promise { + const response = await this.getPaginated( + StockWeekly, + stock_id, + lastStartTime, + ); + + return response; + } +} + +@Injectable() +export class StockDataMonthlyService extends StockDataService { + constructor(dataSource: DataSource) { + super(dataSource); + } + async getStockDataMonthly( + stock_id: string, + lastStartTime?: string, + ): Promise { + const response = await this.getPaginated( + StockMonthly, + stock_id, + lastStartTime, + ); + + return response; + } +} + +@Injectable() +export class StockDataYearlyService extends StockDataService { + constructor(dataSource: DataSource) { + super(dataSource); + } + async getStockDataYearly( + stock_id: string, + lastStartTime?: string, + ): Promise { + const response = await this.getPaginated( + StockYearly, + stock_id, + lastStartTime, + ); + + return response; + } +} From de4506797ffc6ac6a29db1c3b00ee8f379ac5619 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 19:50:54 +0900 Subject: [PATCH 06/16] =?UTF-8?q?=E2=9C=85=20test:=20stockDataService=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/stockData.service.spec.ts | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 packages/backend/src/stock/stockData.service.spec.ts diff --git a/packages/backend/src/stock/stockData.service.spec.ts b/packages/backend/src/stock/stockData.service.spec.ts new file mode 100644 index 0000000..27d14c7 --- /dev/null +++ b/packages/backend/src/stock/stockData.service.spec.ts @@ -0,0 +1,491 @@ +/* eslint-disable max-lines-per-function */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { plainToInstance } from 'class-transformer'; +import { DataSource, EntityManager, SelectQueryBuilder } from 'typeorm'; +import { Stock } from './domain/stock.entity'; +import { + StockMinutely, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, +} from './domain/stockData.entity'; +import { + StockDataResponse, + PriceDto, + VolumeDto, +} from './dto/stockData.response'; +import { StockDataService } from './stockData.service'; + +// Mock DataSource와 EntityManager 생성 함수 +export function createDataSourceMock( + managerMock: Partial, +): Partial { + return { + manager: { + ...managerMock, + transaction: jest.fn().mockImplementation(async (work) => { + return work(managerMock as EntityManager); + }), + } as any, // TypeScript 오류를 피하기 위해 any로 캐스팅 + }; +} + +// QueryBuilder 모킹을 위한 헬퍼 함수 +const createQueryBuilderMock = ( + getManyResult: any[] = [], + throwError: boolean = false, +): Partial> => { + return { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockImplementation(() => { + if (throwError) { + return Promise.reject(new Error('Query error')); + } + return Promise.resolve(getManyResult); + }), + }; +}; + +describe('StockDataService', () => { + const stockId = 'A005930'; + let dataSource: Partial; + let stockDataService: StockDataService; + let managerMock: any; + + beforeEach(() => { + managerMock = { + createQueryBuilder: jest.fn(), + // 필요한 다른 메서드들도 여기에 추가할 수 있습니다. + }; + dataSource = createDataSourceMock(managerMock); + stockDataService = new StockDataService(dataSource as DataSource); + }); + + describe('getPaginated', () => { + const PAGE_SIZE = 100; + + it('주식 데이터를 페이지네이션하여 가져옵니다. hasMore=true', async () => { + const mockData: any[] = Array.from({ length: PAGE_SIZE + 1 }, (_, i) => ({ + id: i, + close: 100 + i, + low: 90 + i, + high: 110 + i, + open: 95 + i, + volume: 1000 + i * 10, + startTime: new Date( + `2023-11-${(10 + (i % 20)).toString().padStart(2, '0')}`, + ), + stock: { id: stockId } as Stock, + createdAt: new Date(), + })); + + const queryBuilderMock = createQueryBuilderMock(mockData); + (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( + queryBuilderMock, + ); + + const response: StockDataResponse = await stockDataService.getPaginated( + StockMinutely, + stockId, + ); + + expect(managerMock.createQueryBuilder).toHaveBeenCalledWith( + StockMinutely, + 'entity', + ); + expect(queryBuilderMock.leftJoinAndSelect).toHaveBeenCalledWith( + 'entity.stock', + 'stock', + ); + expect(queryBuilderMock.where).toHaveBeenCalledWith( + 'entity.stock_id = :stockId', + { stockId }, + ); + expect(queryBuilderMock.orderBy).toHaveBeenCalledWith( + 'entity.startTime', + 'DESC', + ); + expect(queryBuilderMock.take).toHaveBeenCalledWith(PAGE_SIZE + 1); + expect(response.hasMore).toBe(true); + expect(response.priceDtoList).toHaveLength(PAGE_SIZE); + expect(response.volumeDtoList).toHaveLength(PAGE_SIZE); + }); + + it('주식 데이터를 페이지네이션하여 가져옵니다. hasMore=false', async () => { + const mockData: any[] = Array.from({ length: PAGE_SIZE }, (_, i) => ({ + id: i, + close: 100 + i, + low: 90 + i, + high: 110 + i, + open: 95 + i, + volume: 1000 + i * 10, + startTime: new Date( + `2023-11-${(10 + (i % 20)).toString().padStart(2, '0')}`, + ), + stock: { id: stockId } as Stock, + createdAt: new Date(), + })); + + const queryBuilderMock = createQueryBuilderMock(mockData); + (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( + queryBuilderMock, + ); + + const response: StockDataResponse = await stockDataService.getPaginated( + StockMinutely, + stockId, + ); + + expect(response.hasMore).toBe(false); + expect(response.priceDtoList).toHaveLength(PAGE_SIZE); + expect(response.volumeDtoList).toHaveLength(PAGE_SIZE); + }); + + it('lastStartTime을 사용해 이전 데이터까지 페이지네이션 가져오기', async () => { + const lastStartTime = '2023-11-15'; + const mockData: any[] = Array.from({ length: 50 }, (_, i) => ({ + id: i, + close: 100 + i, + low: 90 + i, + high: 110 + i, + open: 95 + i, + volume: 1000 + i * 10, + startTime: new Date( + `2023-11-${(15 - (i % 15)).toString().padStart(2, '0')}`, + ), + stock: { id: stockId } as Stock, + createdAt: new Date(), + })); + + const queryBuilderMock = createQueryBuilderMock(mockData); + (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( + queryBuilderMock, + ); + + const response: StockDataResponse = await stockDataService.getPaginated( + StockMinutely, + stockId, + lastStartTime, + ); + + expect(queryBuilderMock.andWhere).toHaveBeenCalledWith( + 'entity.startTime < :lastStartTime', + { lastStartTime }, + ); + expect(response.hasMore).toBe(false); + expect(response.priceDtoList).toHaveLength(50); + expect(response.volumeDtoList).toHaveLength(50); + }); + + it('쿼리에서 예외가 발생하면 예외를 던집니다.', async () => { + const queryBuilderMock = createQueryBuilderMock([], true); + (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( + queryBuilderMock, + ); + + await expect( + stockDataService.getPaginated(StockMinutely, stockId), + ).rejects.toThrow('Query error'); + }); + }); + + describe('mapResultListToPriceDtoList', () => { + it('StockData 목록을 PriceDto 목록으로 매핑합니다.', () => { + const resultList = [ + { + id: 2, + startTime: new Date('2023-11-11T00:00:00Z'), + open: 105, + close: 115, + high: 120, + low: 100, + volume: 600, + stock: { id: stockId } as Stock, + createdAt: new Date(), + }, + { + id: 1, + startTime: new Date('2023-11-10T00:00:00Z'), + open: 100, + close: 110, + high: 115, + low: 95, + volume: 500, + stock: { id: stockId } as Stock, + createdAt: new Date(), + }, + ]; + + const priceDtoList: PriceDto[] = + stockDataService.mapResultListToPriceDtoList(resultList); + + expect(priceDtoList).toEqual([ + { + startTime: new Date('2023-11-10T00:00:00Z'), + open: 100, + close: 110, + high: 115, + low: 95, + }, + { + startTime: new Date('2023-11-11T00:00:00Z'), + open: 105, + close: 115, + high: 120, + low: 100, + }, + ]); + }); + }); + + describe('mapResultListToVolumeDtoList', () => { + it('StockData 목록을 VolumeDto 목록으로 매핑합니다.', () => { + const resultList = [ + { + id: 3, + startTime: new Date('2023-11-12T00:00:00Z'), + close: 110, + low: 95, + high: 125, + open: 115, + volume: 550, + stock: { id: stockId } as Stock, + createdAt: new Date(), + }, + { + id: 2, + startTime: new Date('2023-11-11T00:00:00Z'), + close: 105, + low: 100, + high: 120, + open: 115, + volume: 600, + stock: { id: stockId } as Stock, + createdAt: new Date(), + }, + { + id: 1, + startTime: new Date('2023-11-10T00:00:00Z'), + close: 100, + low: 90, + high: 110, + open: 95, + volume: 500, + stock: { id: stockId } as Stock, + createdAt: new Date(), + }, + ]; + + const volumeDtoList: VolumeDto[] = + stockDataService.mapResultListToVolumeDtoList(resultList); + + expect(volumeDtoList).toEqual([ + { + startTime: new Date('2023-11-10T00:00:00Z'), + volume: 500, + }, + + { + startTime: new Date('2023-11-11T00:00:00Z'), + volume: 600, + }, + { + startTime: new Date('2023-11-12T00:00:00Z'), + volume: 550, + }, + ]); + }); + }); + + describe('createStockDataResponse', () => { + it('PriceDto와 VolumeDto 목록을 포함한 StockDataResponse 객체를 생성합니다.', () => { + const priceDtoList: PriceDto[] = [ + { + startTime: new Date('2023-11-11T00:00:00Z'), + open: 105, + close: 115, + high: 120, + low: 100, + }, + { + startTime: new Date('2023-11-10T00:00:00Z'), + open: 100, + close: 110, + high: 115, + low: 95, + }, + ]; + + const volumeDtoList: VolumeDto[] = [ + { + startTime: new Date('2023-11-12T00:00:00Z'), + volume: 550, + }, + { + startTime: new Date('2023-11-11T00:00:00Z'), + volume: 600, + }, + { + startTime: new Date('2023-11-10T00:00:00Z'), + volume: 500, + }, + ]; + + const response: StockDataResponse = + stockDataService.createStockDataResponse( + priceDtoList, + volumeDtoList, + true, + ); + + expect(response).toHaveProperty('priceDtoList'); + expect(response).toHaveProperty('volumeDtoList'); + expect(response.hasMore).toBe(true); + expect(response.priceDtoList).toEqual( + plainToInstance(PriceDto, priceDtoList), + ); + expect(response.volumeDtoList).toEqual( + plainToInstance(VolumeDto, volumeDtoList), + ); + }); + }); +}); + +class StockDataMinutelyService extends StockDataService { + async getStockDataMinutely( + stock_id: string, + lastStartTime?: string, + ): Promise { + return await this.getPaginated(StockMinutely, stock_id, lastStartTime); + } +} + +class StockDataDailyService extends StockDataService { + async getStockDataDaily( + stock_id: string, + lastStartTime?: string, + ): Promise { + return await this.getPaginated(StockDaily, stock_id, lastStartTime); + } +} + +class StockDataWeeklyService extends StockDataService { + async getStockDataWeekly( + stock_id: string, + lastStartTime?: string, + ): Promise { + return await this.getPaginated(StockWeekly, stock_id, lastStartTime); + } +} + +class StockDataMonthlyService extends StockDataService { + async getStockDataMonthly( + stock_id: string, + lastStartTime?: string, + ): Promise { + return await this.getPaginated(StockMonthly, stock_id, lastStartTime); + } +} + +class StockDataYearlyService extends StockDataService { + async getStockDataYearly( + stock_id: string, + lastStartTime?: string, + ): Promise { + return await this.getPaginated(StockYearly, stock_id, lastStartTime); + } +} + +describe('StockDataService 파생 클래스 테스트', () => { + const stockId = 'A005930'; + let dataSource: Partial; + let managerMock: any; + + beforeEach(() => { + managerMock = { + createQueryBuilder: jest.fn(), + // 필요한 다른 메서드들도 여기에 추가할 수 있습니다. + }; + dataSource = createDataSourceMock(managerMock); + }); + + const testDerivedService = ( + ServiceClass: any, + EntityClass: any, + methodName: string, + ) => { + describe(`${ServiceClass.name}`, () => { + let service: any; + + beforeEach(() => { + service = new ServiceClass(dataSource as DataSource); + }); + + it(`${methodName} 메서드가 getPaginated를 호출하고 올바른 엔티티를 전달합니다.`, async () => { + const mockResponse: StockDataResponse = { + priceDtoList: [], + volumeDtoList: [], + hasMore: false, + }; + + const getPaginatedSpy = jest + .spyOn(StockDataService.prototype, 'getPaginated') + .mockResolvedValue(mockResponse); + + const response = await service[methodName](stockId); + + expect(getPaginatedSpy).toHaveBeenCalledWith( + EntityClass, + stockId, + undefined, + ); + expect(response).toBe(mockResponse); + + getPaginatedSpy.mockRestore(); + }); + + it(`${methodName} 메서드에 lastStartTime을 전달합니다.`, async () => { + const lastStartTime = '2023-11-15'; + const mockResponse: StockDataResponse = { + priceDtoList: [], + volumeDtoList: [], + hasMore: false, + }; + + const getPaginatedSpy = jest + .spyOn(StockDataService.prototype, 'getPaginated') + .mockResolvedValue(mockResponse); + + const response = await service[methodName](stockId, lastStartTime); + + expect(getPaginatedSpy).toHaveBeenCalledWith( + EntityClass, + stockId, + lastStartTime, + ); + expect(response).toBe(mockResponse); + + getPaginatedSpy.mockRestore(); + }); + }); + }; + + testDerivedService( + StockDataMinutelyService, + StockMinutely, + 'getStockDataMinutely', + ); + testDerivedService(StockDataDailyService, StockDaily, 'getStockDataDaily'); + testDerivedService(StockDataWeeklyService, StockWeekly, 'getStockDataWeekly'); + testDerivedService( + StockDataMonthlyService, + StockMonthly, + 'getStockDataMonthly', + ); + testDerivedService(StockDataYearlyService, StockYearly, 'getStockDataYearly'); +}); From 80950f01bfe3dd989b74aed04586119ad7c1922f Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 23:15:13 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=E2=9C=A8=20feat:=20get=20StockData=20?= =?UTF-8?q?=EB=8D=B0=EC=BD=94=EB=A0=88=EC=9D=B4=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stock/decorator/stockData.decorator.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/backend/src/stock/decorator/stockData.decorator.ts diff --git a/packages/backend/src/stock/decorator/stockData.decorator.ts b/packages/backend/src/stock/decorator/stockData.decorator.ts new file mode 100644 index 0000000..8584f76 --- /dev/null +++ b/packages/backend/src/stock/decorator/stockData.decorator.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable max-lines-per-function */ + +import { applyDecorators } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { StockDataResponse } from '../dto/stockData.response'; + +export function ApiGetStockData(summary: string, type: string) { + return applyDecorators( + ApiOperation({ summary }), + ApiParam({ + name: 'id', + type: Number, + description: '주식 ID', + example: 1, + }), + ApiQuery({ + name: 'lastStartTime', + required: false, + description: '마지막 시작 시간 (ISO 8601 형식)', + example: '2024-04-01T00:00:00.000Z', + type: String, + format: 'date-time', + }), + ApiResponse({ + status: 200, + description: `주식의 ${type} 단위 데이터 성공적으로 조회`, + type: StockDataResponse, + }), + ApiResponse({ + status: 404, + description: '주식 데이터가 존재하지 않음', + }), + ); +} From 77d1c42fa4ef3cf26abc985eac63a718aa21907c Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Wed, 13 Nov 2024 23:18:51 +0900 Subject: [PATCH 08/16] =?UTF-8?q?=E2=9C=A8=20feat:=20stockData=20Minutely?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stock/decorator/stockData.decorator.ts | 6 +-- .../src/stock/dto/stockData.response.ts | 40 +++++++++++++++++++ .../backend/src/stock/stock.controller.ts | 30 +++++++++++++- packages/backend/src/stock/stock.module.ts | 40 ++++++++++++++++++- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/stock/decorator/stockData.decorator.ts b/packages/backend/src/stock/decorator/stockData.decorator.ts index 8584f76..19eb396 100644 --- a/packages/backend/src/stock/decorator/stockData.decorator.ts +++ b/packages/backend/src/stock/decorator/stockData.decorator.ts @@ -9,10 +9,10 @@ export function ApiGetStockData(summary: string, type: string) { return applyDecorators( ApiOperation({ summary }), ApiParam({ - name: 'id', - type: Number, + name: 'stockId', + type: String, description: '주식 ID', - example: 1, + example: 'A005930', }), ApiQuery({ name: 'lastStartTime', diff --git a/packages/backend/src/stock/dto/stockData.response.ts b/packages/backend/src/stock/dto/stockData.response.ts index 520a202..9113371 100644 --- a/packages/backend/src/stock/dto/stockData.response.ts +++ b/packages/backend/src/stock/dto/stockData.response.ts @@ -2,33 +2,73 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; export class PriceDto { + @ApiProperty({ + description: '시작 시간', + type: String, + format: 'date-time', + example: '2024-04-01T00:00:00.000Z', + }) startTime: Date; + + @ApiProperty({ + description: '시가', + example: '121.00', + }) open: number; + + @ApiProperty({ + description: '고가', + example: '125.00', + }) high: number; + + @ApiProperty({ + description: '저가', + example: '120.00', + }) low: number; + + @ApiProperty({ + description: '종가', + example: '123.45', + }) close: number; } export class VolumeDto { + @ApiProperty({ + description: '시작 시간', + type: String, + format: 'date-time', + example: '2024-04-01T00:00:00.000Z', + }) startTime: Date; + + @ApiProperty({ + description: '거래량', + example: 1000, + }) volume: number; } export class StockDataResponse { @ApiProperty({ description: '가격 데이터 배열 (날짜 오름차순)', + type: [PriceDto], }) @Type(() => PriceDto) priceDtoList: PriceDto[]; @ApiProperty({ description: '거래량 데이터 배열 (날짜 오름차순, 색 포함)', + type: [VolumeDto], }) @Type(() => VolumeDto) volumeDtoList: VolumeDto[]; @ApiProperty({ description: '스크롤해서 불러올 수 있는 데이터가 더 존재하는지', + example: true, }) hasMore: boolean; } diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index a5506f0..7d131dd 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -1,6 +1,17 @@ -import { Body, Controller, Delete, HttpCode, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Query, +} from '@nestjs/common'; import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockService } from './stock.service'; +import { StockDataMinutelyService } from './stockData.service'; import { StockViewsResponse } from '@/stock/dto/stock.Response'; import { StockViewRequest } from '@/stock/dto/stockView.request'; import { @@ -11,7 +22,10 @@ import { UserStockResponse } from '@/stock/dto/userStock.response'; @Controller('stock') export class StockController { - constructor(private readonly stockService: StockService) {} + constructor( + private readonly stockService: StockService, + private readonly stockDataMinutelyService: StockDataMinutelyService, + ) {} @HttpCode(200) @Post('/view') @@ -80,4 +94,16 @@ export class StockController { '사용자 소유 주식을 삭제했습니다.', ); } + + @Get(':stockId/minutely') + @ApiGetStockData('주식 분 단위 데이터 조회 API', '분') + async getStockDataMinutely( + @Param('stockId') stockId: string, + @Query() lastStartTime?: string, + ) { + return this.stockDataMinutelyService.getStockDataMinutely( + stockId, + lastStartTime, + ); + } } diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 74e2d42..71091b9 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -1,14 +1,50 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Stock } from './domain/stock.entity'; +import { + StockDaily, + StockMinutely, + StockMonthly, + StockWeekly, + StockYearly, +} from './domain/stockData.entity'; +import { StockLiveData } from './domain/stockLiveData.entity'; import { StockController } from './stock.controller'; import { StockGateway } from './stock.gateway'; import { StockService } from './stock.service'; +import { + StockDataDailyService, + StockDataMinutelyService, + StockDataMonthlyService, + StockDataService, + StockDataWeeklyService, + StockDataYearlyService, +} from './stockData.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; @Module({ - imports: [TypeOrmModule.forFeature([Stock])], + imports: [ + TypeOrmModule.forFeature([ + Stock, + StockMinutely, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, + StockLiveData, + ]), + ], controllers: [StockController], - providers: [StockService, StockGateway, StockLiveDataSubscriber], + providers: [ + StockService, + StockGateway, + StockLiveDataSubscriber, + StockDataService, + StockDataDailyService, + StockDataMinutelyService, + StockDataWeeklyService, + StockDataYearlyService, + StockDataMonthlyService, + ], }) export class StockModule {} From f57da6cf93b6054ceaa0bd2b4c0fd8c4e894d316 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Thu, 14 Nov 2024 10:21:39 +0900 Subject: [PATCH 09/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.gateway.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 04df988..df04ac7 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -8,7 +8,7 @@ import { import { Server, Socket } from 'socket.io'; @WebSocketGateway({ - path: '/stock', + path: '/realtimeStock', }) export class StockGateway { @WebSocketServer() From 7c4c13fbe6740cb4e1057c4273ff60eebe504d99 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Thu, 14 Nov 2024 10:33:09 +0900 Subject: [PATCH 10/16] =?UTF-8?q?=E2=9C=A8=20feat:=20stockData=20Daily,Wee?= =?UTF-8?q?kly...=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stock.controller.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 7d131dd..4d63100 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -11,7 +11,13 @@ import { import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockService } from './stock.service'; -import { StockDataMinutelyService } from './stockData.service'; +import { + StockDataDailyService, + StockDataMinutelyService, + StockDataMonthlyService, + StockDataWeeklyService, + StockDataYearlyService, +} from './stockData.service'; import { StockViewsResponse } from '@/stock/dto/stock.Response'; import { StockViewRequest } from '@/stock/dto/stockView.request'; import { @@ -25,6 +31,10 @@ export class StockController { constructor( private readonly stockService: StockService, private readonly stockDataMinutelyService: StockDataMinutelyService, + private readonly stockDataDailyService: StockDataDailyService, + private readonly stockDataWeeklyService: StockDataWeeklyService, + private readonly stockDataMonthlyService: StockDataMonthlyService, + private readonly stockDataYearlyService: StockDataYearlyService, ) {} @HttpCode(200) @@ -106,4 +116,49 @@ export class StockController { lastStartTime, ); } + + @Get(':stockId/daily') + @ApiGetStockData('주식 일 단위 데이터 조회 API', '일') + async getStockDataDaily( + @Param('stockId') stockId: string, + @Query() lastStartTime?: string, + ) { + return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); + } + + @Get(':stockId/weekly') + @ApiGetStockData('주식 주 단위 데이터 조회 API', '주') + async getStockDataWeekly( + @Param('stockId') stockId: string, + @Query() lastStartTime?: string, + ) { + return this.stockDataWeeklyService.getStockDataWeekly( + stockId, + lastStartTime, + ); + } + + @Get(':stockId/mothly') + @ApiGetStockData('주식 월 단위 데이터 조회 API', '월') + async getStockDataMonthly( + @Param('stockId') stockId: string, + @Query() lastStartTime?: string, + ) { + return this.stockDataMonthlyService.getStockDataMonthly( + stockId, + lastStartTime, + ); + } + + @Get(':stockId/yearly') + @ApiGetStockData('주식 연 단위 데이터 조회 API', '연') + async getStockDataYearly( + @Param('stockId') stockId: string, + @Query() lastStartTime?: string, + ) { + return this.stockDataYearlyService.getStockDataYearly( + stockId, + lastStartTime, + ); + } } From af5e5dae63a26ef145ab1d5d9c62993a1b74814b Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Thu, 14 Nov 2024 11:47:45 +0900 Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=88=98=EC=A0=95,=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=9B=A8=EC=9D=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.controller.ts | 10 +++++----- packages/backend/src/stock/stock.gateway.ts | 2 +- packages/backend/src/stock/stockData.service.ts | 1 - 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 4d63100..80b7e59 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -109,7 +109,7 @@ export class StockController { @ApiGetStockData('주식 분 단위 데이터 조회 API', '분') async getStockDataMinutely( @Param('stockId') stockId: string, - @Query() lastStartTime?: string, + @Query('lastStartTime') lastStartTime?: string, ) { return this.stockDataMinutelyService.getStockDataMinutely( stockId, @@ -121,7 +121,7 @@ export class StockController { @ApiGetStockData('주식 일 단위 데이터 조회 API', '일') async getStockDataDaily( @Param('stockId') stockId: string, - @Query() lastStartTime?: string, + @Query('lastStartTime') lastStartTime?: string, ) { return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); } @@ -130,7 +130,7 @@ export class StockController { @ApiGetStockData('주식 주 단위 데이터 조회 API', '주') async getStockDataWeekly( @Param('stockId') stockId: string, - @Query() lastStartTime?: string, + @Query('lastStartTime') lastStartTime?: string, ) { return this.stockDataWeeklyService.getStockDataWeekly( stockId, @@ -142,7 +142,7 @@ export class StockController { @ApiGetStockData('주식 월 단위 데이터 조회 API', '월') async getStockDataMonthly( @Param('stockId') stockId: string, - @Query() lastStartTime?: string, + @Query('lastStartTime') lastStartTime?: string, ) { return this.stockDataMonthlyService.getStockDataMonthly( stockId, @@ -154,7 +154,7 @@ export class StockController { @ApiGetStockData('주식 연 단위 데이터 조회 API', '연') async getStockDataYearly( @Param('stockId') stockId: string, - @Query() lastStartTime?: string, + @Query('lastStartTime') lastStartTime?: string, ) { return this.stockDataYearlyService.getStockDataYearly( stockId, diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index df04ac7..cf98907 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -8,7 +8,7 @@ import { import { Server, Socket } from 'socket.io'; @WebSocketGateway({ - path: '/realtimeStock', + namespace: '/stock/realtime', }) export class StockGateway { @WebSocketServer() diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 7fa0703..694f7a3 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -42,7 +42,6 @@ export class StockDataService { return await this.dataSource.manager.transaction(async (manager) => { const queryBuilder = manager .createQueryBuilder(entity, 'entity') - .leftJoinAndSelect('entity.stock', 'stock') .where('entity.stock_id = :stockId', { stockId: stock_id }) .orderBy('entity.startTime', 'DESC') .take(this.PAGE_SIZE + 1); From 6c949ede7d856c7320779c4870c438c6f207a8ef Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Thu, 14 Nov 2024 12:11:57 +0900 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?NotFound=20Exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stockData.service.spec.ts | 14 +++++++++----- packages/backend/src/stock/stockData.service.ts | 5 ++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/stock/stockData.service.spec.ts b/packages/backend/src/stock/stockData.service.spec.ts index 27d14c7..1858a03 100644 --- a/packages/backend/src/stock/stockData.service.spec.ts +++ b/packages/backend/src/stock/stockData.service.spec.ts @@ -60,7 +60,7 @@ describe('StockDataService', () => { beforeEach(() => { managerMock = { createQueryBuilder: jest.fn(), - // 필요한 다른 메서드들도 여기에 추가할 수 있습니다. + exists: jest.fn().mockResolvedValue(true), }; dataSource = createDataSourceMock(managerMock); stockDataService = new StockDataService(dataSource as DataSource); @@ -69,6 +69,14 @@ describe('StockDataService', () => { describe('getPaginated', () => { const PAGE_SIZE = 100; + it('주식이 존재하지 않을 경우 NotFoundException을 던집니다.', async () => { + managerMock.exists.mockResolvedValue(false); + + await expect( + stockDataService.getPaginated(StockMinutely, stockId), + ).rejects.toThrow('stock not found'); + }); + it('주식 데이터를 페이지네이션하여 가져옵니다. hasMore=true', async () => { const mockData: any[] = Array.from({ length: PAGE_SIZE + 1 }, (_, i) => ({ id: i, @@ -98,10 +106,6 @@ describe('StockDataService', () => { StockMinutely, 'entity', ); - expect(queryBuilderMock.leftJoinAndSelect).toHaveBeenCalledWith( - 'entity.stock', - 'stock', - ); expect(queryBuilderMock.where).toHaveBeenCalledWith( 'entity.stock_id = :stockId', { stockId }, diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 694f7a3..80151a2 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { DataSource } from 'typeorm'; import { Stock } from './domain/stock.entity'; @@ -40,6 +40,9 @@ export class StockDataService { lastStartTime?: string, ): Promise { return await this.dataSource.manager.transaction(async (manager) => { + if (!(await manager.exists(Stock, { where: { id: stock_id } }))) + throw new NotFoundException('stock not found'); + const queryBuilder = manager .createQueryBuilder(entity, 'entity') .where('entity.stock_id = :stockId', { stockId: stock_id }) From 47d6901c6fe4fd2994da95d55308306088caee3e Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 01:06:19 +0900 Subject: [PATCH 13/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B3=B5=ED=86=B5=20=EB=B6=80=EB=B6=84=20=EC=83=81?= =?UTF-8?q?=EC=86=8D=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/domain/stockData.entity.ts | 126 ++---------------- 1 file changed, 8 insertions(+), 118 deletions(-) diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts index 62fd9ec..e7a7cea 100644 --- a/packages/backend/src/stock/domain/stockData.entity.ts +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -8,8 +8,7 @@ import { } from 'typeorm'; import { Stock } from './stock.entity'; -@Entity('stock_minutely') -export class StockMinutely { +abstract class StockData { @PrimaryGeneratedColumn() id: number; @@ -39,123 +38,14 @@ export class StockMinutely { createdAt: Date; } -@Entity('stock_daily') -export class StockDaily { - @PrimaryGeneratedColumn() - id: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - close: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - low: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - high: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - open: number; - - @Column({ type: 'bigint' }) - volume: number; - - @Column({ type: 'timestamp', name: 'start_time' }) - startTime: Date; - - @OneToOne(() => Stock) - @JoinColumn({ name: 'stock_id' }) - stock: Stock; +@Entity('stock_minutely') +export class StockMinutely extends StockData {} - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} +@Entity('stock_daily') +export class StockDaily extends StockData {} @Entity('stock_weekly') -export class StockWeekly { - @PrimaryGeneratedColumn() - id: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - close: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - low: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - high: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - open: number; - - @Column({ type: 'bigint' }) - volume: number; - - @Column({ type: 'timestamp', name: 'start_time' }) - startTime: Date; - - @OneToOne(() => Stock) - @JoinColumn({ name: 'stock_id' }) - stock: Stock; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} +export class StockWeekly extends StockData {} @Entity('stock_monthly') -export class StockMonthly { - @PrimaryGeneratedColumn() - id: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - close: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - low: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - high: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - open: number; - - @Column({ type: 'bigint' }) - volume: number; - - @Column({ type: 'timestamp', name: 'start_time' }) - startTime: Date; - - @OneToOne(() => Stock) - @JoinColumn({ name: 'stock_id' }) - stock: Stock; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} +export class StockMonthly extends StockData {} @Entity('stock_yearly') -export class StockYearly { - @PrimaryGeneratedColumn() - id: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - close: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - low: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - high: number; - - @Column({ type: 'decimal', precision: 15, scale: 2 }) - open: number; - - @Column({ type: 'bigint' }) - volume: number; - - @Column({ type: 'timestamp', name: 'start_time' }) - startTime: Date; - - @OneToOne(() => Stock) - @JoinColumn({ name: 'stock_id' }) - stock: Stock; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; -} +export class StockYearly extends StockData {} From 174eaedbdcada790f26f968b1343c873f7307930 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 01:12:39 +0900 Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20stockData=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20Stock=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B4=80=EA=B3=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/domain/stockData.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts index e7a7cea..4a309a5 100644 --- a/packages/backend/src/stock/domain/stockData.entity.ts +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -3,8 +3,8 @@ import { PrimaryGeneratedColumn, Column, CreateDateColumn, - OneToOne, JoinColumn, + ManyToOne, } from 'typeorm'; import { Stock } from './stock.entity'; @@ -30,7 +30,7 @@ abstract class StockData { @Column({ type: 'timestamp', name: 'start_time' }) startTime: Date; - @OneToOne(() => Stock) + @ManyToOne(() => Stock) @JoinColumn({ name: 'stock_id' }) stock: Stock; From 24669c4703ec4e70fd7c1fac0b0b7c4b2d0c6409 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 01:17:59 +0900 Subject: [PATCH 15/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=EC=A1=B4=EC=9E=AC=20=EC=9C=A0=EB=AC=B4=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stockData.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 80151a2..56932ed 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; -import { DataSource } from 'typeorm'; +import { DataSource, EntityManager } from 'typeorm'; import { Stock } from './domain/stock.entity'; import { StockMinutely, @@ -40,7 +40,7 @@ export class StockDataService { lastStartTime?: string, ): Promise { return await this.dataSource.manager.transaction(async (manager) => { - if (!(await manager.exists(Stock, { where: { id: stock_id } }))) + if (!(await this.isStockExist(stock_id, manager))) throw new NotFoundException('stock not found'); const queryBuilder = manager @@ -65,6 +65,10 @@ export class StockDataService { }); } + async isStockExist(stockId: string, manager: EntityManager) { + return await manager.exists(Stock, { where: { id: stockId } }); + } + mapResultListToPriceDtoList(resultList: StockData[]): PriceDto[] { return resultList .map((data: StockData) => ({ From 0429d823988622863bf36980ccc7e03a58a2baef Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 01:33:26 +0900 Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=90=9B=20fix:=20coflict=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stock.controller.ts | 21 ++++++++++++++++++- packages/backend/src/stock/stock.module.ts | 4 ++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 80b7e59..0b09765 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -8,8 +8,9 @@ import { Post, Query, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; import { ApiGetStockData } from './decorator/stockData.decorator'; +import { StockDetailResponse } from './dto/stockDetail.response'; import { StockService } from './stock.service'; import { StockDataDailyService, @@ -18,6 +19,7 @@ import { StockDataWeeklyService, StockDataYearlyService, } from './stockData.service'; +import { StockDetailService } from './stockDetail.service'; import { StockViewsResponse } from '@/stock/dto/stock.Response'; import { StockViewRequest } from '@/stock/dto/stockView.request'; import { @@ -35,6 +37,7 @@ export class StockController { private readonly stockDataWeeklyService: StockDataWeeklyService, private readonly stockDataMonthlyService: StockDataMonthlyService, private readonly stockDataYearlyService: StockDataYearlyService, + private readonly stockDetailService: StockDetailService, ) {} @HttpCode(200) @@ -161,4 +164,20 @@ export class StockController { lastStartTime, ); } + + @ApiOperation({ + summary: '주식 상세 정보 조회 API', + description: '시가 총액, EPS, PER, 52주 최고가, 52주 최저가를 조회합니다', + }) + @ApiOkResponse({ + description: '주식 상세 정보 조회 성공', + type: StockDetailResponse, + }) + @ApiParam({ name: 'stockId', required: true, description: '주식 ID' }) + @Get(':stockId/detail') + async getStockDetail( + @Param('stockId') stockId: string, + ): Promise { + return await this.stockDetailService.getStockDetailByStockId(stockId); + } } diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 71091b9..4ccdd0a 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -8,6 +8,7 @@ import { StockWeekly, StockYearly, } from './domain/stockData.entity'; +import { StockDetail } from './domain/stockDetail.entity'; import { StockLiveData } from './domain/stockLiveData.entity'; import { StockController } from './stock.controller'; import { StockGateway } from './stock.gateway'; @@ -20,6 +21,7 @@ import { StockDataWeeklyService, StockDataYearlyService, } from './stockData.service'; +import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; @Module({ @@ -32,6 +34,7 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; StockMonthly, StockYearly, StockLiveData, + StockDetail, ]), ], controllers: [StockController], @@ -45,6 +48,7 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; StockDataWeeklyService, StockDataYearlyService, StockDataMonthlyService, + StockDetailService, ], }) export class StockModule {}