From e796f91d1b618319be5740ab105cb49014b8062f Mon Sep 17 00:00:00 2001 From: anniemon Date: Fri, 6 Dec 2024 15:36:11 +0900 Subject: [PATCH 1/4] =?UTF-8?q?misc:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=EB=AA=85=20snake=20case=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.development.yaml | 2 +- docker/mysql/init-backend.sql | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.development.yaml b/docker-compose.development.yaml index 37403d4..cf78a52 100644 --- a/docker-compose.development.yaml +++ b/docker-compose.development.yaml @@ -6,7 +6,7 @@ services: command: --init-file /init-backend.sql environment: - MYSQL_ROOT_PASSWORD=root - - MYSQL_DATABASE=e-commerce_local + - MYSQL_DATABASE=e_commerce_local - MYSQL_USER=anniemon - MYSQL_PASSWORD=anniemon - MYSQL_TCP_PORT=3307 diff --git a/docker/mysql/init-backend.sql b/docker/mysql/init-backend.sql index c749265..11c8f4f 100644 --- a/docker/mysql/init-backend.sql +++ b/docker/mysql/init-backend.sql @@ -1,6 +1,6 @@ -CREATE DATABASE IF NOT EXISTS `e-commerce_local` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -DROP DATABASE IF EXISTS `e-commerce_test`; -CREATE DATABASE IF NOT EXISTS `e-commerce_test` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +CREATE DATABASE IF NOT EXISTS `e_commerce_local` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +DROP DATABASE IF EXISTS `e_commerce_test`; +CREATE DATABASE IF NOT EXISTS `e_commerce_test` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY 'root'; GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; CREATE USER IF NOT EXISTS 'anniemon'@'%' IDENTIFIED BY 'anniemon'; From 40188aa546f0f10f8853a45aeeda7f7a2368b9c2 Mon Sep 17 00:00:00 2001 From: anniemon Date: Fri, 6 Dec 2024 15:39:28 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20e2e=20test,=20autoLoadEntities=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/application/modules/app.module.ts | 5 ++++- src/infrastructure/typeorm/data-source/index.ts | 4 ++-- test/e2e/app.e2e-spec.ts | 3 +-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 362e247..fa1594c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest --config ./jest.json --verbose --runInBand", "test:unit": "jest --config ./jest.json --verbose --runInBand --testRegex ./test/unit/.*\\.spec\\.ts$", - "test:e2e": "jest --config ./jest.json --verbose --runInBand --testRegex ./test/e2e/.*\\.spec\\.ts$", + "test:e2e": "MYSQL_DATABASE=e_commerce_test jest --config ./jest.json --verbose --runInBand --testRegex ./test/e2e/.*\\.spec\\.ts$ --forceExit", "test:watch": "jest --watch --forceExit", "test:coverage": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", diff --git a/src/application/modules/app.module.ts b/src/application/modules/app.module.ts index 78fcf97..bd75df4 100644 --- a/src/application/modules/app.module.ts +++ b/src/application/modules/app.module.ts @@ -10,7 +10,10 @@ const modulesList = Object.keys(modules).map( ); @Module({ - imports: [...modulesList, TypeOrmModule.forRoot(dataSource.options)], + imports: [ + ...modulesList, + TypeOrmModule.forRoot({ ...dataSource.options, autoLoadEntities: true }), + ], providers: [ AppService, { diff --git a/src/infrastructure/typeorm/data-source/index.ts b/src/infrastructure/typeorm/data-source/index.ts index 1ddb865..cd861cf 100644 --- a/src/infrastructure/typeorm/data-source/index.ts +++ b/src/infrastructure/typeorm/data-source/index.ts @@ -1,5 +1,6 @@ import { DataSource, DataSourceOptions } from 'typeorm'; +const { MYSQL_DATABASE } = process.env; // TODO: 환경별 설정 분리 export const dataSourceOptions: DataSourceOptions = { type: 'mysql', @@ -8,8 +9,7 @@ export const dataSourceOptions: DataSourceOptions = { bigNumberStrings: false, username: 'root', password: 'root', - database: `e-commerce_local`, - migrationsRun: true, + database: MYSQL_DATABASE ?? 'e_commerce_local', entities: [__dirname + '/../entities/*.entity{.ts,.js}'], migrations: [__dirname + '/../migrations/*{.ts,.js}'], synchronize: true, diff --git a/test/e2e/app.e2e-spec.ts b/test/e2e/app.e2e-spec.ts index 94d9a07..e51be38 100644 --- a/test/e2e/app.e2e-spec.ts +++ b/test/e2e/app.e2e-spec.ts @@ -15,8 +15,7 @@ describe('AppController (e2e)', () => { await app.init(); }); - // TODO: 로컬 테스트 환경 설정 후 활성화 - it.skip('/ (GET)', () => { + it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) From 0d529e025146db95aa0fae547b5f9791dc7b69de Mon Sep 17 00:00:00 2001 From: anniemon Date: Fri, 6 Dec 2024 15:39:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20unit=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=8F=B4=EB=8D=94=EB=A1=9C=20=EC=98=AE=EA=B8=B4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/order-facade.spec.ts | 251 ----------------------------------- test/product-service.spec.ts | 74 ----------- 2 files changed, 325 deletions(-) delete mode 100644 test/order-facade.spec.ts delete mode 100644 test/product-service.spec.ts diff --git a/test/order-facade.spec.ts b/test/order-facade.spec.ts deleted file mode 100644 index dd459bd..0000000 --- a/test/order-facade.spec.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { DataSource } from 'typeorm'; -import { Test, TestingModule } from '@nestjs/testing'; -import { OrderFacade } from '@domain/usecase'; -import { OrderService, PointService, ProductService } from '@domain/services'; - -describe('OrderFacade', () => { - let orderFacade: OrderFacade; - - const productService = { - findProductsByIdsWithStock: jest.fn(), - decrementStockWithLock: jest.fn(), - }; - const orderService = { - createOrder: jest.fn(), - }; - const pointService = { - usePointWithLock: jest.fn(), - }; - const queryRunner = { - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - }; - const dataSource = { - createQueryRunner: jest.fn().mockReturnValue(queryRunner), - }; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - providers: [ - OrderFacade, - { - provide: ProductService, - useValue: productService, - }, - { - provide: OrderService, - useValue: orderService, - }, - { - provide: DataSource, - useValue: dataSource, - }, - { - provide: PointService, - useValue: pointService, - }, - ], - }).compile(); - - orderFacade = moduleFixture.get(OrderFacade); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('createOrderWithTransaction: 주문 생성 성공', () => { - it('재고와 유저 잔액이 충분할 때 주문 생성이 성공해야 한다.', async () => { - const userId = 1; - const items = [ - { productId: 1, quantity: 1 }, - { productId: 2, quantity: 2 }, - ]; - - productService.decrementStockWithLock.mockResolvedValueOnce({ - inStockProductIds: [1, 2], - outOfStockProductIds: [], - }); - productService.findProductsByIdsWithStock.mockResolvedValueOnce([ - { id: 1, price: 100, quantity: 1 }, - { id: 2, price: 200, quantity: 2 }, - ]); - - pointService.usePointWithLock.mockResolvedValueOnce({ - id: 1, - balance: 1000, - }); - - orderService.createOrder.mockResolvedValueOnce({ id: 1, userId, items }); - - const result = await orderFacade.createOrderWithTransaction({ - userId, - items, - }); - - expect(result.order.id).toBe(1); - expect(result.totalPrice).toBe(500); - expect(productService.decrementStockWithLock).toHaveBeenCalledWith({ - items, - queryRunner, - }); - expect(productService.findProductsByIdsWithStock).toHaveBeenCalledWith([ - 1, 2, - ]); - expect(pointService.usePointWithLock).toHaveBeenCalledWith({ - queryRunner, - userId, - amount: 500, - }); - expect(orderService.createOrder).toHaveBeenCalledWith({ - userId, - items: [ - { productId: 1, quantity: 1, price: 100 }, - { productId: 2, quantity: 2, price: 200 }, - ], - queryRunner, - }); - expect(queryRunner.commitTransaction).toHaveBeenCalled(); - expect(queryRunner.release).toHaveBeenCalled(); - expect(queryRunner.rollbackTransaction).not.toHaveBeenCalled(); - }); - - it('재고가 있는 상품이 하나라도 있으면 주문 생성이 성공해야 한다.', async () => { - const userId = 1; - const items = [ - { productId: 1, quantity: 1 }, - { productId: 2, quantity: 2 }, - ]; - - productService.decrementStockWithLock.mockResolvedValueOnce({ - inStockProductIds: [1], - outOfStockProductIds: [2], - }); - - productService.findProductsByIdsWithStock.mockResolvedValueOnce([ - { id: 1, price: 100, quantity: 1 }, - ]); - - pointService.usePointWithLock.mockResolvedValueOnce({ - id: 1, - balance: 1000, - }); - - orderService.createOrder.mockResolvedValueOnce({ - id: 1, - userId, - items: [{ orderId: 1, productId: 1, quantity: 1, price: 100 }], - }); - - const result = await orderFacade.createOrderWithTransaction({ - userId, - items, - }); - - expect(result.order.id).toBe(1); - expect(result.totalPrice).toBe(100); - expect(result.order.items.length).toBe(1); - expect(productService.decrementStockWithLock).toHaveBeenCalledWith({ - items, - queryRunner, - }); - expect(orderService.createOrder).toHaveBeenCalledWith({ - userId, - items: [{ productId: 1, quantity: 1, price: 100 }], - queryRunner, - }); - expect(orderService.createOrder).toHaveBeenCalledTimes(1); - expect(queryRunner.commitTransaction).toHaveBeenCalled(); - expect(queryRunner.release).toHaveBeenCalled(); - expect(queryRunner.rollbackTransaction).not.toHaveBeenCalled(); - }); - }); - - describe('createOrderWithTransaction: 주문 생성 실패', () => { - it('모든 상품의 재고가 없으면 주문 생성이 실패하고 트랜잭션이 롤백되어야 한다.', async () => { - const userId = 1; - const items = [ - { productId: 1, quantity: 1 }, - { productId: 2, quantity: 2 }, - ]; - - productService.decrementStockWithLock.mockRejectedValueOnce( - new Error('Out of stock'), - ); - productService.findProductsByIdsWithStock.mockResolvedValueOnce([]); - pointService.usePointWithLock.mockResolvedValueOnce({ - id: 1, - balance: 1000, - }); - orderService.createOrder.mockResolvedValueOnce({ id: 1, userId, items }); - - await expect( - orderFacade.createOrderWithTransaction({ - userId, - items, - }), - ).rejects.toThrow(Error); - - expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunner.release).toHaveBeenCalled(); - }); - - it('주문 생성 중 에러 발생 시 주문 생성이 실패하고 트랜잭션이 롤백되어야 한다.', async () => { - const userId = 1; - const items = [ - { productId: 1, quantity: 1 }, - { productId: 2, quantity: 2 }, - ]; - - productService.decrementStockWithLock.mockResolvedValueOnce({ - inStockProductIds: [1, 2], - outOfStockProductIds: [], - }); - productService.findProductsByIdsWithStock.mockResolvedValueOnce([ - { id: 1, price: 100, quantity: 1 }, - { id: 2, price: 200, quantity: 2 }, - ]); - pointService.usePointWithLock.mockResolvedValueOnce({ - id: 1, - balance: 1000, - }); - orderService.createOrder = jest.fn().mockRejectedValueOnce(new Error()); - - await expect( - orderFacade.createOrderWithTransaction({ - userId, - items, - }), - ).rejects.toThrow(Error); - expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunner.release).toHaveBeenCalled(); - }); - - it('포인트 차감 중 에러 발생 시 주문 생성이 실패하고 트랜잭션이 롤백되어야 한다.', async () => { - const userId = 1; - const items = [ - { productId: 1, quantity: 1 }, - { productId: 2, quantity: 2 }, - ]; - - productService.decrementStockWithLock.mockResolvedValueOnce({ - inStockProductIds: [1, 2], - outOfStockProductIds: [], - }); - productService.findProductsByIdsWithStock.mockResolvedValueOnce([ - { id: 1, price: 100, quantity: 1 }, - { id: 2, price: 200, quantity: 2 }, - ]); - pointService.usePointWithLock = jest.fn().mockRejectedValueOnce(Error()); - orderService.createOrder.mockResolvedValueOnce({ id: 1, userId, items }); - - await expect( - orderFacade.createOrderWithTransaction({ userId, items }), - ).rejects.toThrow(Error); - expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunner.release).toHaveBeenCalled(); - }); - }); -}); diff --git a/test/product-service.spec.ts b/test/product-service.spec.ts deleted file mode 100644 index 8b84b1c..0000000 --- a/test/product-service.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ProductService } from '@domain/services'; -import { ProductRepository, StockRepository } from '@domain/repositories'; - -describe('ProductService', () => { - let productService: ProductService; - const productRepository = { - getProductByIdWithStock: jest.fn().mockResolvedValue({ id: 1 }), - findProductsByIdsWithStock: jest - .fn() - .mockResolvedValue([{ id: 1 }, { id: 2 }]), - }; - const stockRepository = { - decrementStockWithLock: jest.fn().mockResolvedValue({ id: 1 }), - }; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - providers: [ - ProductService, - { - provide: ProductRepository, - useValue: productRepository, - }, - { - provide: StockRepository, - useValue: stockRepository, - }, - ], - }).compile(); - - productService = moduleFixture.get(ProductService); - }); - - it('getProductByIdWithStock: 상품 아이디로 상품 조회 메서드를 호출해야 한다.', async () => { - const productId = 1; - const product = await productService.getProductByIdWithStock(productId); - - expect(product.id).toBe(productId); - expect(productRepository.getProductByIdWithStock).toHaveBeenCalledWith( - productId, - ); - expect(productRepository.getProductByIdWithStock).toHaveBeenCalledTimes(1); - }); - - it('findProductsByIdsWithStock: 상품 아이디 배열로 상품 조회 메서드를 호출해야 한다.', async () => { - const productIds = [1, 2]; - const products = - await productService.findProductsByIdsWithStock(productIds); - - expect(products.length).toBe(productIds.length); - expect(productRepository.findProductsByIdsWithStock).toHaveBeenCalledWith( - productIds, - ); - expect(productRepository.findProductsByIdsWithStock).toHaveBeenCalledTimes( - 1, - ); - }); - - it('decrementStockWithLock: 주문 상품 갯수만큼 상품 재고 차감 메서드를 호출해야 한다.', async () => { - const items = [ - { productId: 1, quantity: 1 }, - { productId: 2, quantity: 2 }, - ]; - await productService.decrementStockWithLock({ - items, - queryRunner: null, - }); - - expect(stockRepository.decrementStockWithLock).toHaveBeenCalledTimes( - items.length, - ); - }); -}); From 033ac8074176a164b3589b802e9c9d65e13d75c9 Mon Sep 17 00:00:00 2001 From: anniemon Date: Fri, 6 Dec 2024 15:43:28 +0900 Subject: [PATCH 4/4] =?UTF-8?q?ci:=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dc5a38..4b0c059 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs -name: Node.js CI +name: CI Test on: push: @@ -10,13 +10,22 @@ on: branches: [main] jobs: - build: + ci-test: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + workspace: ['test:unit', 'test:e2e'] + + services: + mysql: + image: mysql:8.0.23 + ports: + - 3307:3306 + env: + MYSQL_DATABASE: e_commerce_test + MYSQL_ROOT_PASSWORD: root steps: - uses: actions/checkout@v4 @@ -27,4 +36,10 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build --if-present - - run: npm test + - name: init database + run: | + sudo /etc/init.d/mysql start + mysql -h 127.0.0.1 -P 3307 -uroot -proot -e "CREATE DATABASE IF NOT EXISTS e_commerce_test;" + mysql -h 127.0.0.1 -P 3307 -uroot -proot e_commerce_test < ./docker/mysql/init-backend.sql + - name: npm run ${{ matrix.workspace }} + run: npm run ${{ matrix.workspace }}