From a52fd0db8f595fca9847908f0b54bbea68a5fdbd Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:49:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20watchlist=20API=20=ED=97=AC?= =?UTF-8?q?=ED=8D=BC=20+=20useWatchlist=20=ED=9B=85(=EB=82=99=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20CRUD=C2=B7=EC=95=8C=EB=A6=BC)=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/api.js | 10 +++ src/pages/stock/hooks/useWatchlist.js | 91 ++++++++++++++++++++++ src/pages/stock/hooks/useWatchlist.test.js | 80 +++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/pages/stock/hooks/useWatchlist.js create mode 100644 src/pages/stock/hooks/useWatchlist.test.js diff --git a/src/api.js b/src/api.js index daca5d8..604f502 100644 --- a/src/api.js +++ b/src/api.js @@ -852,3 +852,13 @@ export function compatPatchReading(id, body) { export function compatDeleteReading(id) { return apiDelete(`/api/saju/compat/readings/${id}`); } + +// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ── +// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] } +// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true } +// DELETE /api/stock/watchlist/{ticker} → 200/404 +// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } +export const getWatchlist = () => apiGet('/api/stock/watchlist'); +export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); +export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`); +export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`); diff --git a/src/pages/stock/hooks/useWatchlist.js b/src/pages/stock/hooks/useWatchlist.js new file mode 100644 index 0000000..3e59ccb --- /dev/null +++ b/src/pages/stock/hooks/useWatchlist.js @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; +import { normalizeTicker } from '../watchlistUtils'; + +const asArray = (data, key) => { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data[key])) return data[key]; + return []; +}; + +const byFiredAtDesc = (a, b) => + new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime(); + +export default function useWatchlist() { + const [items, setItems] = useState([]); + const [alerts, setAlerts] = useState([]); + const [alertDays, setAlertDays] = useState(7); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [alertError, setAlertError] = useState(''); + const [adding, setAdding] = useState(false); + + const loadWatchlist = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await getWatchlist(); + setItems(asArray(data, 'watchlist')); + } catch (e) { + setError(e?.message ?? String(e)); + } finally { + setLoading(false); + } + }, []); + + const loadAlerts = useCallback(async (days) => { + setAlertError(''); + try { + const data = await getTradeAlerts(days); + setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc)); + } catch (e) { + setAlertError(e?.message ?? String(e)); + setAlerts([]); + } + }, []); + + useEffect(() => { loadWatchlist(); }, [loadWatchlist]); + useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]); + + const add = useCallback(async ({ ticker, name, note }) => { + const t = normalizeTicker(ticker); + if (!t) return; + if (items.some((it) => it.ticker === t)) { + setError(`이미 관심종목에 있습니다: ${t}`); + return; + } + setAdding(true); + setError(''); + const cleanName = (name ?? '').trim(); + const cleanNote = (note ?? '').trim(); + const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() }; + setItems((prev) => [optimistic, ...prev]); + try { + await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined }); + await loadWatchlist(); + } catch (e) { + setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백 + setError(e?.message ?? String(e)); + } finally { + setAdding(false); + } + }, [items, loadWatchlist]); + + const remove = useCallback(async (ticker) => { + const prev = items; + setItems((cur) => cur.filter((it) => it.ticker !== ticker)); + setError(''); + try { + await removeWatchlist(ticker); + } catch (e) { + setItems(prev); // 롤백 + setError(e?.message ?? String(e)); + } + }, [items]); + + return { + items, alerts, alertDays, setAlertDays, + loading, error, alertError, adding, + add, remove, reload: loadWatchlist, + }; +} diff --git a/src/pages/stock/hooks/useWatchlist.test.js b/src/pages/stock/hooks/useWatchlist.test.js new file mode 100644 index 0000000..6931d4c --- /dev/null +++ b/src/pages/stock/hooks/useWatchlist.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +vi.mock('../../../api', () => ({ + getWatchlist: vi.fn(), + addWatchlist: vi.fn(), + removeWatchlist: vi.fn(), + getTradeAlerts: vi.fn(), +})); + +import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; +import useWatchlist from './useWatchlist'; + +beforeEach(() => { + vi.clearAllMocks(); + getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] }); + getTradeAlerts.mockResolvedValue({ alerts: [] }); + addWatchlist.mockResolvedValue({ ok: true }); + removeWatchlist.mockResolvedValue({ ok: true }); +}); + +describe('useWatchlist', () => { + it('마운트 시 watchlist를 로드', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.items[0].ticker).toBe('005930'); + }); + + it('배열 직접 반환도 방어적으로 파싱', async () => { + getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.items[0].ticker).toBe('000660'); + }); + + it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => { + getWatchlist + .mockResolvedValueOnce({ watchlist: [] }) + .mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] }); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); }); + expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined }); + await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true)); + }); + + it('add 실패 시 롤백 + error', async () => { + getWatchlist.mockResolvedValue({ watchlist: [] }); + addWatchlist.mockRejectedValue(new Error('HTTP 500 err')); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.add({ ticker: '000660' }); }); + await waitFor(() => expect(result.current.error).toContain('HTTP 500')); + expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false); + }); + + it('중복 ticker는 add 차단', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await act(async () => { await result.current.add({ ticker: '005930' }); }); + expect(addWatchlist).not.toHaveBeenCalled(); + expect(result.current.error).toContain('이미'); + }); + + it('remove: 낙관적 제거 + DELETE 호출', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await act(async () => { await result.current.remove('005930'); }); + expect(removeWatchlist).toHaveBeenCalledWith('005930'); + expect(result.current.items).toHaveLength(0); + }); + + it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => { + getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing')); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await waitFor(() => expect(result.current.alertError).toContain('HTTP 404')); + expect(result.current.alerts).toHaveLength(0); + }); +});