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:
2026-07-03 01:49:44 +09:00
parent ae33aa4def
commit a52fd0db8f
3 changed files with 181 additions and 0 deletions

View File

@@ -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}`);

View 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,
};
}

View 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);
});
});