Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

## 📚 개발 후기 및 기술 블로그

프로젝트 개발 과정에서 겪은 기술적 도전과 해결 과정을 정리한 문서들입니다:
Expand Down
61 changes: 61 additions & 0 deletions scripts/backend.sh
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions scripts/demo.sh
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions src/app/provider/testing/stompTestUtils.tsx
Original file line number Diff line number Diff line change
@@ -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: <explanation>
} as any;

export const mockStompContextValue = {
client: mockClient,
connected: true,
};

export const StompTestWrapper = ({ children }: { children: ReactNode }) => (
<StompContext.Provider value={mockStompContextValue}>
{children}
</StompContext.Provider>
);
46 changes: 19 additions & 27 deletions src/entities/coin/hooks/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 }) => (
<StompContext.Provider value={mockStompContextValue}>
{children}
</StompContext.Provider>
);

describe('useCurrentPrice 훅 테스트', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -94,7 +83,7 @@ describe('useCurrentPrice 훅 테스트', () => {
);

const { result } = renderHook(() => useCurrentPrice(TICKER_FIRST), {
wrapper,
wrapper: StompTestWrapper,
});

await waitFor(() => {
Expand All @@ -104,7 +93,7 @@ describe('useCurrentPrice 훅 테스트', () => {

it('ticker가 변경되면 새로운 구독을 생성한다', () => {
const { rerender } = renderHook(({ ticker }) => useCurrentPrice(ticker), {
wrapper,
wrapper: StompTestWrapper,
initialProps: { ticker: TICKER_FIRST },
});

Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,8 +9,10 @@ import CoinListItem from '.';
const props: CoinListItemProps = {
name: '비트코인',
ticker: 'BTC',
coinIcon: <span>🪙</span>,
to: '/coin/BTC',
currentPrice: 0,
changeRate: 0,
svgIconBase64: '',
};

const Stub = createRoutesStub([
Expand Down Expand Up @@ -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<typeof import('react-router')>('react-router');
return {
...actual,
useNavigate: () => navigate,
createRoutesStub: actual.createRoutesStub,
};
});

describe('CoinListItem 컴포넌트 테스트', () => {
it('화면에 CoinListItem이 렌더링 된다.', () => {
render(<Stub initialEntries={['/coin']} />);

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(<Stub initialEntries={['/coin']} />);

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,28 @@ vi.mock('@stomp/stompjs', () => {
const props: CoinListWithSearchBarProps = {
coinList: [
{
coinIcon: <span>🪙</span>,
name: '비트코인',
ticker: 'BTC',
to: '/coin/BTC',
currentPrice: 0,
changeRate: 0,
svgIconBase64: '',
},
{
coinIcon: <span>🪙</span>,
name: '이더리움',
ticker: 'ETH',
to: '/coin/ETH',
currentPrice: 0,
changeRate: 0,
svgIconBase64: '',
},
{
coinIcon: <span>🪙</span>,
name: '트럼프',
ticker: 'TRUMP',
to: '/coin/TRUMP',
currentPrice: 0,
changeRate: 0,
svgIconBase64: '',
},
],
};
Expand All @@ -65,7 +71,7 @@ describe('CoinListWithSearchBar 컴포넌트 테스트', () => {
);

expect(coinListWithSearchBar).toBeInTheDocument();
expect(screen.getAllByRole('link')).toHaveLength(3);
expect(screen.getAllByRole('button')).toHaveLength(3);
});

it('사용자가 검색창에 티커를 입력하면 필터링된 리스트가 렌더링 된다.', async () => {
Expand All @@ -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('비트코인');
});
});
Loading
Loading