diff --git a/README.md b/README.md index 6fd6cbc..645c255 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,27 @@ **๐ŸŒ ์„œ๋น„์Šค URL:** ~~https://investfuture.my~~ AWS ์ง€์› ์ค‘๋‹จ์œผ๋กœ ๋กœ์ปฌ์‹คํ–‰๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. +## ๐Ÿ–ฅ๏ธ ๋กœ์ปฌ ์‹คํ–‰ ๋ฐฉ๋ฒ• + +### ๐Ÿ“‹ Prerequisite +- **Git** +- **Node.js** +- **Yarn** +- **Docker** + +์•„๋ž˜ ๋ช…๋ น์–ด๋ฅผ ํ„ฐ๋ฏธ๋„์— ๋ถ™์—ฌ๋„ฃ๊ธฐ ํ•˜์„ธ์š”. +```bash +git clone https://github.com/CleanEngine/cleanengine-fe.git +cd cleanengine-fe + +chmod +x scripts/demo.sh + +./scripts/demo.sh +``` + +์Šคํฌ๋ฆฝํŠธ๊ฐ€ ์‹คํ–‰๋œ ํ›„ ๋‹ค์Œ์ฃผ์†Œ์— ์ ‘์†ํ•˜์„ธ์š”. +- **ํ”„๋ก ํŠธ์—”๋“œ**: http://localhost:3000 + ## ๐Ÿ“š ๊ฐœ๋ฐœ ํ›„๊ธฐ ๋ฐ ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ ํ”„๋กœ์ ํŠธ ๊ฐœ๋ฐœ ๊ณผ์ •์—์„œ ๊ฒช์€ ๊ธฐ์ˆ ์  ๋„์ „๊ณผ ํ•ด๊ฒฐ ๊ณผ์ •์„ ์ •๋ฆฌํ•œ ๋ฌธ์„œ๋“ค์ž…๋‹ˆ๋‹ค: diff --git a/scripts/backend.sh b/scripts/backend.sh new file mode 100755 index 0000000..222db88 --- /dev/null +++ b/scripts/backend.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +backend_dir_name="cleanengine-be" +backend_repo_url="https://github.com/CleanEngine/cleanengine-be.git" + + +DEMO_KAKAO_CLIENT_ID=2e063b83bf69bf8e54db000d056539b2 +DEMO_JWT_SECRET=my-super-secret-key-for-jwt-generation-in-investfuture-project +DEMO_MARIADB_ROOT_PASSWORD=1234 +DEMO_MARIADB_DATABASE=if +DEMO_MARIADB_USER=localuser +DEMO_MARIADB_PASSWORD=localpass +DEMO_SPRING_DATASOURCE_URL=jdbc:mariadb://mariadb:3306/if +DEMO_SPRING_DATASOURCE_USERNAME=localuser +DEMO_SPRING_DATASOURCE_PASSWORD=localpass + +function build_backend(){ + + if [ ! -f "local.properties" ]; then + echo "local.properties ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. ํ•ด๋‹น ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค." + touch local.properties + echo "KAKAO_CLIENT_ID=${DEMO_KAKAO_CLIENT_ID}" >> local.properties + echo "JWT_SECRET=${DEMO_JWT_SECRET}" >> local.properties + echo "MARIADB_ROOT_PASSWORD=${DEMO_MARIADB_ROOT_PASSWORD}" >> local.properties + echo "MARIADB_DATABASE=${DEMO_MARIADB_DATABASE}" >> local.properties + echo "MARIADB_USER=${DEMO_MARIADB_USER}" >> local.properties + echo "MARIADB_PASSWORD=${DEMO_MARIADB_PASSWORD}" >> local.properties + echo "SPRING_DATASOURCE_URL=${DEMO_SPRING_DATASOURCE_URL}" >> local.properties + echo "SPRING_DATASOURCE_USERNAME=${DEMO_SPRING_DATASOURCE_USERNAME}" >> local.properties + echo "SPRING_DATASOURCE_PASSWORD=${DEMO_SPRING_DATASOURCE_PASSWORD}" >> local.properties + fi + + ./gradlew clean build +} + +function run_docker(){ + if ! docker info > /dev/null 2>&1; then + echo "Docker ์—”์ง„์ด ์‹คํ–‰๋˜์ง€ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. Docker Desktop์„ ์‹œ์ž‘ํ•ด์ฃผ์„ธ์š”." + exit 1 + fi + + docker compose -f docker/docker-compose.yml up -d +} + + +cd "${SCRIPT_DIR}/../.." + +if [ ! -d "${backend_dir_name}" ]; then + echo "๋ฐฑ์—”๋“œ ๋ ˆํฌ์ง€ํ† ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ํ•ด๋‹น ๋ ˆํฌ์ง€ํ† ๋ฆฌ๋ฅผ ํด๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค." + git clone "${backend_repo_url}" + cd "${backend_dir_name}" + git checkout main +else + echo "๋ฐฑ์—”๋“œ ๋ ˆํฌ์ง€ํ† ๋ฆฌ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์ตœ์‹  ๋ฒ„์ „์œผ๋กœ ์—…๋ฐ์ดํŠธํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค." + cd "${backend_dir_name}" + git pull origin main + git checkout main +fi + +build_backend +run_docker diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..d33f317 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +export SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +chmod +x "${SCRIPT_DIR}/backend.sh" + +"${SCRIPT_DIR}/backend.sh" + +if lsof -i :3000 > /dev/null 2>&1; then + echo "3000๋ฒˆ ํฌํŠธ๊ฐ€ ์‚ฌ์šฉ ์ค‘์ž…๋‹ˆ๋‹ค. ํ•ด๋‹น ํ”„๋กœ์„ธ์Šค๋ฅผ ์ข…๋ฃŒํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (y/n)" + read -r answer + if [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then + echo "ํ”„๋กœ์„ธ์Šค๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค." + kill -9 $(lsof -ti :3000) + else + echo "ํ”„๋กœ์„ธ์Šค ์ข…๋ฃŒ๋ฅผ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค. ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค." + exit 1 + fi +fi + +yarn start \ No newline at end of file diff --git a/src/app/provider/testing/stompTestUtils.tsx b/src/app/provider/testing/stompTestUtils.tsx new file mode 100644 index 0000000..f8fd9f5 --- /dev/null +++ b/src/app/provider/testing/stompTestUtils.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; +import { vi } from 'vitest'; +import { StompContext } from '~/app/provider/StompProvider'; + +export const mockClient = { + publish: vi.fn(), + subscribe: vi.fn(() => ({ + unsubscribe: vi.fn(), + id: 'testId', + })), + // biome-ignore lint/suspicious/noExplicitAny: +} as any; + +export const mockStompContextValue = { + client: mockClient, + connected: true, +}; + +export const StompTestWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); diff --git a/src/entities/coin/hooks/hooks.test.tsx b/src/entities/coin/hooks/hooks.test.tsx index aa72041..763c25a 100644 --- a/src/entities/coin/hooks/hooks.test.tsx +++ b/src/entities/coin/hooks/hooks.test.tsx @@ -1,8 +1,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; -import { beforeEach, describe, expect } from 'vitest'; -import { it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { StompContext } from '~/app/provider/StompProvider'; +import { + StompTestWrapper, + mockClient, +} from '~/app/provider/testing/stompTestUtils'; import useCurrentPrice, { type CurrentPriceData } from './useCurrentPrice'; const TICKER_FIRST = 'BTC'; @@ -16,25 +20,6 @@ function generateTopicEndPoint(ticker: string) { return `/topic/prevRate/${ticker}`; } -const mockClient = { - publish: vi.fn(), - subscribe: vi.fn(() => ({ - unsubscribe: vi.fn(), - id: 'testId', - })), -} as any; - -const mockStompContextValue = { - client: mockClient, - connected: true, -}; - -const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -); - describe('useCurrentPrice ํ›… ํ…Œ์ŠคํŠธ', () => { beforeEach(() => { vi.clearAllMocks(); @@ -56,7 +41,9 @@ describe('useCurrentPrice ํ›… ํ…Œ์ŠคํŠธ', () => { }); it('ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์—ฐ๊ฒฐ๋˜๋ฉด ์˜ฌ๋ฐ”๋ฅธ destination์œผ๋กœ publishํ•œ๋‹ค', () => { - renderHook(() => useCurrentPrice(TICKER_FIRST), { wrapper }); + renderHook(() => useCurrentPrice(TICKER_FIRST), { + wrapper: StompTestWrapper, + }); expect(mockClient.publish).toHaveBeenCalledWith({ destination: generateDestinationEndPoint(TICKER_FIRST), @@ -65,7 +52,9 @@ describe('useCurrentPrice ํ›… ํ…Œ์ŠคํŠธ', () => { }); it('์˜ฌ๋ฐ”๋ฅธ topic์œผ๋กœ subscribeํ•œ๋‹ค', () => { - renderHook(() => useCurrentPrice(TICKER_FIRST), { wrapper }); + renderHook(() => useCurrentPrice(TICKER_FIRST), { + wrapper: StompTestWrapper, + }); expect(mockClient.subscribe).toHaveBeenCalledWith( generateTopicEndPoint(TICKER_FIRST), @@ -94,7 +83,7 @@ describe('useCurrentPrice ํ›… ํ…Œ์ŠคํŠธ', () => { ); const { result } = renderHook(() => useCurrentPrice(TICKER_FIRST), { - wrapper, + wrapper: StompTestWrapper, }); await waitFor(() => { @@ -104,7 +93,7 @@ describe('useCurrentPrice ํ›… ํ…Œ์ŠคํŠธ', () => { it('ticker๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด ์ƒˆ๋กœ์šด ๊ตฌ๋…์„ ์ƒ์„ฑํ•œ๋‹ค', () => { const { rerender } = renderHook(({ ticker }) => useCurrentPrice(ticker), { - wrapper, + wrapper: StompTestWrapper, initialProps: { ticker: TICKER_FIRST }, }); @@ -118,10 +107,13 @@ describe('useCurrentPrice ํ›… ํ…Œ์ŠคํŠธ', () => { it('์ปดํฌ๋„ŒํŠธ๊ฐ€ ์–ธ๋งˆ์šดํŠธ๋˜๋ฉด ๊ตฌ๋…์„ ํ•ด์ œํ•œ๋‹ค', () => { const mockUnsubscribe = vi.fn(); - mockClient.subscribe.mockReturnValue({ unsubscribe: mockUnsubscribe }); + mockClient.subscribe.mockReturnValue({ + unsubscribe: mockUnsubscribe, + id: 'testId', + }); const { unmount } = renderHook(() => useCurrentPrice(TICKER_FIRST), { - wrapper, + wrapper: StompTestWrapper, }); unmount(); diff --git a/src/features/coin-search-list/ui/CoinListItem/CoinListItem.test.tsx b/src/features/coin-search-list/ui/CoinListItem/CoinListItem.test.tsx index c3aa6ff..bc2a4a5 100644 --- a/src/features/coin-search-list/ui/CoinListItem/CoinListItem.test.tsx +++ b/src/features/coin-search-list/ui/CoinListItem/CoinListItem.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { createRoutesStub } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; import type { CurrentPriceData } from '~/entities/coin/hooks/useCurrentPrice'; @@ -8,8 +9,10 @@ import CoinListItem from '.'; const props: CoinListItemProps = { name: '๋น„ํŠธ์ฝ”์ธ', ticker: 'BTC', - coinIcon: ๐Ÿช™, to: '/coin/BTC', + currentPrice: 0, + changeRate: 0, + svgIconBase64: '', }; const Stub = createRoutesStub([ @@ -37,20 +40,37 @@ vi.mock('~/entities/coin', async () => { }; }); +const { navigate } = vi.hoisted(() => ({ + navigate: vi.fn(), +})); + +vi.mock('react-router', async () => { + const actual = + await vi.importActual('react-router'); + return { + ...actual, + useNavigate: () => navigate, + createRoutesStub: actual.createRoutesStub, + }; +}); + describe('CoinListItem ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ', () => { it('ํ™”๋ฉด์— CoinListItem์ด ๋ Œ๋”๋ง ๋œ๋‹ค.', () => { render(); - const coinListItem = screen.getByRole('link'); + const coinListItem = screen.getByRole('button'); expect(coinListItem).toBeInTheDocument(); }); - it('Link์˜ to ์†์„ฑ์œผ๋กœ prop์˜ to๊ฐ€ ์ „๋‹ฌ๋œ๋‹ค.', () => { + it('์‚ฌ์šฉ์ž๊ฐ€ CoinListItem์„ ํด๋ฆญํ•˜๋ฉด navigate๊ฐ€ ํ˜ธ์ถœ๋œ๋‹ค.', async () => { + const user = userEvent.setup(); render(); - const coinListItem = screen.getByRole('link'); - + const coinListItem = screen.getByRole('button'); expect(coinListItem).toBeInTheDocument(); - expect(coinListItem).toHaveAttribute('href', '/coin/BTC'); + + await user.click(coinListItem); + + expect(navigate).toHaveBeenCalledWith('/coin/BTC'); }); }); diff --git a/src/features/coin-search-list/ui/CoinListWithSearchBar/CoinListWithSearchBar.test.tsx b/src/features/coin-search-list/ui/CoinListWithSearchBar/CoinListWithSearchBar.test.tsx index 4db360c..071adc3 100644 --- a/src/features/coin-search-list/ui/CoinListWithSearchBar/CoinListWithSearchBar.test.tsx +++ b/src/features/coin-search-list/ui/CoinListWithSearchBar/CoinListWithSearchBar.test.tsx @@ -25,22 +25,28 @@ vi.mock('@stomp/stompjs', () => { const props: CoinListWithSearchBarProps = { coinList: [ { - coinIcon: ๐Ÿช™, name: '๋น„ํŠธ์ฝ”์ธ', ticker: 'BTC', to: '/coin/BTC', + currentPrice: 0, + changeRate: 0, + svgIconBase64: '', }, { - coinIcon: ๐Ÿช™, name: '์ด๋”๋ฆฌ์›€', ticker: 'ETH', to: '/coin/ETH', + currentPrice: 0, + changeRate: 0, + svgIconBase64: '', }, { - coinIcon: ๐Ÿช™, name: 'ํŠธ๋Ÿผํ”„', ticker: 'TRUMP', to: '/coin/TRUMP', + currentPrice: 0, + changeRate: 0, + svgIconBase64: '', }, ], }; @@ -65,7 +71,7 @@ describe('CoinListWithSearchBar ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ', () => { ); expect(coinListWithSearchBar).toBeInTheDocument(); - expect(screen.getAllByRole('link')).toHaveLength(3); + expect(screen.getAllByRole('button')).toHaveLength(3); }); it('์‚ฌ์šฉ์ž๊ฐ€ ๊ฒ€์ƒ‰์ฐฝ์— ํ‹ฐ์ปค๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ํ•„ํ„ฐ๋ง๋œ ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋ Œ๋”๋ง ๋œ๋‹ค.', async () => { @@ -81,7 +87,7 @@ describe('CoinListWithSearchBar ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ', () => { const input = screen.getByRole('textbox'); await user.type(input, 'BTC'); - expect(screen.getAllByRole('link')).toHaveLength(1); - expect(screen.getAllByRole('link')[0]).toHaveTextContent('๋น„ํŠธ์ฝ”์ธ'); + expect(screen.getAllByRole('button')).toHaveLength(1); + expect(screen.getAllByRole('button')[0]).toHaveTextContent('๋น„ํŠธ์ฝ”์ธ'); }); }); diff --git a/src/features/order-execution-list/hooks/hooks.test.tsx b/src/features/order-execution-list/hooks/hooks.test.tsx new file mode 100644 index 0000000..488bc6c --- /dev/null +++ b/src/features/order-execution-list/hooks/hooks.test.tsx @@ -0,0 +1,99 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StompContext } from '~/app/provider/StompProvider'; +import { + StompTestWrapper, + mockClient, +} from '~/app/provider/testing/stompTestUtils'; +import type { Execution } from '../types/execution.type'; +import useExecutionListData from './useExecutionListData'; + +const TICKER = 'BTC'; + +const MOCK_EXECUTION_ITEM: Execution = { + price: 1000, + size: 1, + timestamp: new Date().toISOString(), + changeRate: 3, + transactionId: '1', + ticker: 'BTC', +}; + +function generateDestinationEndPoint(ticker: string) { + return `/app/subscribe/realTimeTradeRate/${ticker}`; +} + +function generateTopicEndPoint(ticker: string) { + return `/topic/realTimeTradeRate/${ticker}`; +} + +describe('useExecutionListData ํ…Œ์ŠคํŠธ', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์—ฐ๊ฒฐ๋˜์ง€ ์•Š์•˜์„ ๋•Œ๋Š” ๋นˆ ๋ฐฐ์—ด์„ ๋ฆฌํ„ดํ•œ๋‹ค.', () => { + const disconnectedWrapper = ({ + children, + }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useExecutionListData(TICKER), { + wrapper: disconnectedWrapper, + }); + + expect(mockClient.publish).not.toHaveBeenCalled(); + expect(mockClient.subscribe).not.toHaveBeenCalled(); + + expect(result.current).toEqual([]); + }); + + it('ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์—ฐ๊ฒฐ๋˜๊ณ  ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜ค๋ฉด ์ฒด๊ฒฐ๋‚ด์—ญ์˜ ๋ฐฐ์—ด์„ ๋ฆฌํ„ดํ•œ๋‹ค.', async () => { + (mockClient.subscribe as any).mockImplementation( + (destination: string, callback: (message: any) => void) => { + setTimeout(() => { + callback({ body: JSON.stringify(MOCK_EXECUTION_ITEM) }); + }, 0); + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useExecutionListData(TICKER), { + wrapper: StompTestWrapper, + }); + + expect(mockClient.publish).toHaveBeenCalledWith({ + destination: generateDestinationEndPoint(TICKER), + body: JSON.stringify({ ticker: TICKER }), + }); + + expect(mockClient.subscribe).toHaveBeenCalledWith( + generateTopicEndPoint(TICKER), + expect.any(Function), + ); + + await waitFor(() => { + expect(result.current).toEqual([MOCK_EXECUTION_ITEM]); + }); + }); + + it('์–ธ๋งˆ์šดํŠธ๊ฐ€ ๋˜๋ฉด ๊ตฌ๋…์„ ํ•ด์ œํ•œ๋‹ค.', () => { + const mockUnsubscribe = vi.fn(); + mockClient.subscribe.mockReturnValue({ + unsubscribe: mockUnsubscribe, + id: 'testId', + }); + + const { unmount } = renderHook(() => useExecutionListData(TICKER), { + wrapper: StompTestWrapper, + }); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); +});