From 6bf36f34f01668a6f08c4a8ae9bcf55d31c787e1 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 02:13:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(stock):=20watchlist=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=ED=81=AC=EB=9E=98=EC=8B=9C=20=EA=B0=80=EB=93=9C=C2=B7=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EC=8B=9C=20=ED=8F=BC=20=EB=A6=AC=EC=85=8B=C2=B7?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - watchlistUtils: Object.hasOwn 가드 + Object.freeze (프로토타입 키 → 함수 반환 방지) - useWatchlist.add: boolean 반환 + 재진입 가드; 성공 시에만 폼 리셋 - byFiredAtDesc 멀티 알림 정렬 테스트 추가 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/stock/components/WatchlistTab.jsx | 4 ++-- src/pages/stock/hooks/useWatchlist.js | 9 +++++--- src/pages/stock/hooks/useWatchlist.test.js | 23 ++++++++++++++++++--- src/pages/stock/watchlistUtils.js | 18 ++++++++-------- src/pages/stock/watchlistUtils.test.js | 14 +++++++++++++ 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/pages/stock/components/WatchlistTab.jsx b/src/pages/stock/components/WatchlistTab.jsx index aff97ab..08038f5 100644 --- a/src/pages/stock/components/WatchlistTab.jsx +++ b/src/pages/stock/components/WatchlistTab.jsx @@ -34,8 +34,8 @@ const WatchlistTab = ({ wl }) => { const submit = async (e) => { e.preventDefault(); if (!form.ticker.trim()) return; - await wl.add(form); - setForm({ ticker: '', name: '', note: '' }); + const ok = await wl.add(form); + if (ok) setForm({ ticker: '', name: '', note: '' }); }; return ( diff --git a/src/pages/stock/hooks/useWatchlist.js b/src/pages/stock/hooks/useWatchlist.js index 3e59ccb..761d7aa 100644 --- a/src/pages/stock/hooks/useWatchlist.js +++ b/src/pages/stock/hooks/useWatchlist.js @@ -48,11 +48,12 @@ export default function useWatchlist() { useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]); const add = useCallback(async ({ ticker, name, note }) => { + if (adding) return false; const t = normalizeTicker(ticker); - if (!t) return; + if (!t) return false; if (items.some((it) => it.ticker === t)) { setError(`이미 관심종목에 있습니다: ${t}`); - return; + return false; } setAdding(true); setError(''); @@ -63,13 +64,15 @@ export default function useWatchlist() { try { await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined }); await loadWatchlist(); + return true; } catch (e) { setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백 setError(e?.message ?? String(e)); + return false; } finally { setAdding(false); } - }, [items, loadWatchlist]); + }, [items, loadWatchlist, adding]); const remove = useCallback(async (ticker) => { const prev = items; diff --git a/src/pages/stock/hooks/useWatchlist.test.js b/src/pages/stock/hooks/useWatchlist.test.js index 6931d4c..c44c241 100644 --- a/src/pages/stock/hooks/useWatchlist.test.js +++ b/src/pages/stock/hooks/useWatchlist.test.js @@ -39,8 +39,10 @@ describe('useWatchlist', () => { .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하이닉스' }); }); + let ok; + await act(async () => { ok = await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); }); expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined }); + expect(ok).toBe(true); await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true)); }); @@ -49,7 +51,9 @@ describe('useWatchlist', () => { 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' }); }); + let ok; + await act(async () => { ok = await result.current.add({ ticker: '000660' }); }); + expect(ok).toBe(false); await waitFor(() => expect(result.current.error).toContain('HTTP 500')); expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false); }); @@ -57,9 +61,11 @@ describe('useWatchlist', () => { 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' }); }); + let ok; + await act(async () => { ok = await result.current.add({ ticker: '005930' }); }); expect(addWatchlist).not.toHaveBeenCalled(); expect(result.current.error).toContain('이미'); + expect(ok).toBe(false); }); it('remove: 낙관적 제거 + DELETE 호출', async () => { @@ -77,4 +83,15 @@ describe('useWatchlist', () => { await waitFor(() => expect(result.current.alertError).toContain('HTTP 404')); expect(result.current.alerts).toHaveLength(0); }); + + it('alerts를 fired_at 내림차순으로 정렬', async () => { + getTradeAlerts.mockResolvedValue({ alerts: [ + { id: 1, ticker: 'A', fired_at: '2026-07-01T00:00:00Z' }, + { id: 2, ticker: 'B', fired_at: '2026-07-03T00:00:00Z' }, + { id: 3, ticker: 'C', fired_at: '2026-07-02T00:00:00Z' }, + ] }); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.alerts).toHaveLength(3)); + expect(result.current.alerts.map((a) => a.id)).toEqual([2, 3, 1]); + }); }); diff --git a/src/pages/stock/watchlistUtils.js b/src/pages/stock/watchlistUtils.js index 3afa0d6..25dce70 100644 --- a/src/pages/stock/watchlistUtils.js +++ b/src/pages/stock/watchlistUtils.js @@ -1,19 +1,18 @@ /* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */ -export const KIND_META = { - buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }, - sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, -}; +export const KIND_META = Object.freeze({ + buy: Object.freeze({ label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }), + sell: Object.freeze({ label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }), +}); const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' }; export const kindMeta = (kind) => { - const meta = KIND_META[kind]; - if (meta) return meta; + if (Object.hasOwn(KIND_META, kind)) return KIND_META[kind]; return { ...FALLBACK_KIND, label: kind ?? '' }; }; -export const CONDITION_LABEL = { +export const CONDITION_LABEL = Object.freeze({ buy_ma20_pullback: 'MA20 눌림 반등', buy_breakout: '박스 상단 돌파', buy_rsi_bounce: 'RSI 과매도 반등', @@ -22,9 +21,10 @@ export const CONDITION_LABEL = { sell_take_profit: '목표가 도달', sell_climax: '과열 소진', sell_trailing_stop: '트레일링 스톱', -}; +}); -export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? ''; +export const conditionLabel = (cond) => + Object.hasOwn(CONDITION_LABEL, cond) ? CONDITION_LABEL[cond] : (cond ?? ''); export const normalizeTicker = (str) => String(str ?? '').trim(); diff --git a/src/pages/stock/watchlistUtils.test.js b/src/pages/stock/watchlistUtils.test.js index 4e694cf..edbccc6 100644 --- a/src/pages/stock/watchlistUtils.test.js +++ b/src/pages/stock/watchlistUtils.test.js @@ -32,6 +32,20 @@ describe('conditionLabel', () => { }); }); +describe('프로토타입 키 방어 (render-safe)', () => { + it('conditionLabel은 상속 키에도 문자열을 반환', () => { + expect(conditionLabel('toString')).toBe('toString'); + expect(typeof conditionLabel('toString')).toBe('string'); + expect(typeof conditionLabel('constructor')).toBe('string'); + }); + it('kindMeta는 상속 키에도 문자열 label + 회색 폴백', () => { + const m = kindMeta('constructor'); + expect(typeof m.label).toBe('string'); + expect(m.label).toBe('constructor'); + expect(m.color).toBe('#94a3b8'); + }); +}); + describe('normalizeTicker', () => { it('공백 trim', () => { expect(normalizeTicker(' 005930 ')).toBe('005930');