feat(stock): watchlist API 헬퍼 + useWatchlist 훅(낙관적 CRUD·알림) + 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
10
src/api.js
10
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}`);
|
||||
|
||||
91
src/pages/stock/hooks/useWatchlist.js
Normal file
91
src/pages/stock/hooks/useWatchlist.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
80
src/pages/stock/hooks/useWatchlist.test.js
Normal file
80
src/pages/stock/hooks/useWatchlist.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user