Skip to content

Commit 4659da2

Browse files
authored
Merge pull request #38 from interruping/feat/vcr-quotation-cassettes
test: VCR 카세트 녹화 — 시세 API 통합 테스트
2 parents cf444ba + 878b336 commit 4659da2

16 files changed

+7995
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,4 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
6363
[tool.pyright]
6464
venvPath = "."
6565
venv = ".venv"
66+
extraPaths = ["."]

tests/__init__.py

Whitespace-only changes.

tests/_vcr.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""VCR 설정 및 커스텀 YAML serializer.
2+
3+
- 기본 record_mode="none" → CI에서 실 API 호출 차단
4+
- VCR_RECORD_MODE 환경변수로 녹화 모드 전환
5+
- 응답 body를 pretty-print JSON + YAML literal block scalar(|)로 저장
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
import sys
13+
from typing import Any
14+
15+
import vcr
16+
import yaml
17+
from vcr.record_mode import RecordMode
18+
19+
# ── Pretty JSON body ────────────────────────────────────────────────────
20+
21+
22+
def _pretty_json_body(response: dict) -> dict:
23+
"""응답 body가 JSON이면 정렬·들여쓰기하여 카세트에 읽기 좋게 저장한다."""
24+
body = response.get("body", {}).get("string", "")
25+
# 리플레이 시 bytes로 로드된 body는 건드리지 않는다
26+
if isinstance(body, bytes):
27+
return response
28+
try:
29+
parsed = json.loads(body)
30+
response["body"]["string"] = json.dumps(
31+
parsed, indent=2, ensure_ascii=False, sort_keys=False
32+
)
33+
except (json.JSONDecodeError, TypeError):
34+
pass
35+
return response
36+
37+
38+
# ── YAML literal block scalar ───────────────────────────────────────────
39+
40+
41+
class _LiteralStr(str):
42+
"""yaml dumper가 literal block scalar(|)로 출력하도록 표시하는 래퍼."""
43+
44+
45+
def _literal_representer(dumper: yaml.Dumper, data: _LiteralStr) -> Any:
46+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
47+
48+
49+
yaml.add_representer(_LiteralStr, _literal_representer)
50+
51+
52+
def _mark_multiline(obj: Any) -> Any:
53+
"""dict/list를 재귀 탐색하며 개행이 포함된 문자열을 _LiteralStr로 감싼다."""
54+
if isinstance(obj, dict):
55+
return {k: _mark_multiline(v) for k, v in obj.items()}
56+
if isinstance(obj, list):
57+
return [_mark_multiline(v) for v in obj]
58+
if isinstance(obj, str) and "\n" in obj:
59+
return _LiteralStr(obj)
60+
return obj
61+
62+
63+
# ── Serialize / Deserialize ─────────────────────────────────────────────
64+
65+
66+
def _ensure_body_bytes(cassette_dict: dict) -> dict:
67+
"""vcrpy 리플레이 시 body를 bytes로 기대하므로 str → bytes 변환."""
68+
for interaction in cassette_dict.get("interactions", []):
69+
for key in ("request", "response"):
70+
body = interaction.get(key, {}).get("body")
71+
if isinstance(body, dict) and isinstance(
72+
body.get("string"), str
73+
):
74+
body["string"] = body["string"].encode("utf-8")
75+
return cassette_dict
76+
77+
78+
def serialize(cassette_dict: dict) -> str:
79+
return yaml.dump(
80+
_mark_multiline(cassette_dict),
81+
default_flow_style=False,
82+
allow_unicode=True,
83+
)
84+
85+
86+
def deserialize(cassette_string: str) -> Any:
87+
data = yaml.safe_load(cassette_string)
88+
return _ensure_body_bytes(data)
89+
90+
91+
# ── VCR 인스턴스 ────────────────────────────────────────────────────────
92+
93+
# tests/_vcr 모듈 자체가 serialize/deserialize를 갖고 있으므로
94+
# register_serializer에 모듈을 직접 등록한다.
95+
_this_module = sys.modules[__name__]
96+
97+
upbeat_vcr = vcr.VCR(
98+
cassette_library_dir="tests/cassettes",
99+
filter_headers=[("Authorization", "REDACTED")],
100+
filter_query_parameters=["access_key"],
101+
decode_compressed_response=True,
102+
record_mode=RecordMode(os.environ.get("VCR_RECORD_MODE", "none")),
103+
before_record_response=_pretty_json_body,
104+
)
105+
upbeat_vcr.register_serializer("pretty-yaml", _this_module)
106+
upbeat_vcr.serializer = "pretty-yaml"

tests/api/test_quotation_vcr.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
from __future__ import annotations
2+
3+
from tests.conftest import upbeat_vcr
4+
from upbeat import Upbeat
5+
from upbeat.types.quotation import (
6+
CandleDay,
7+
CandleMinute,
8+
CandlePeriod,
9+
CandleSecond,
10+
Orderbook,
11+
OrderbookInstrument,
12+
Ticker,
13+
Trade,
14+
)
15+
16+
17+
def _client() -> Upbeat:
18+
return Upbeat(max_retries=0, auto_throttle=False)
19+
20+
21+
# ── Tickers ─────────────────────────────────────────────────────────────
22+
23+
24+
class TestGetTickers:
25+
@upbeat_vcr.use_cassette("quotation/get_tickers.yaml")
26+
def test_returns_valid_tickers(self) -> None:
27+
with _client() as client:
28+
result = client.quotation.get_tickers("KRW-BTC")
29+
30+
assert len(result) >= 1
31+
ticker = result[0]
32+
assert isinstance(ticker, Ticker)
33+
assert ticker.market == "KRW-BTC"
34+
assert ticker.change in ("EVEN", "RISE", "FALL")
35+
assert isinstance(ticker.trade_price, float)
36+
assert isinstance(ticker.timestamp, int)
37+
38+
39+
class TestGetTickersByQuote:
40+
@upbeat_vcr.use_cassette("quotation/get_tickers_by_quote.yaml")
41+
def test_returns_valid_tickers(self) -> None:
42+
with _client() as client:
43+
result = client.quotation.get_tickers_by_quote("KRW")
44+
45+
assert len(result) >= 1
46+
for ticker in result:
47+
assert isinstance(ticker, Ticker)
48+
assert ticker.market.startswith("KRW-")
49+
50+
51+
# ── Candles ─────────────────────────────────────────────────────────────
52+
53+
54+
class TestGetCandlesMinutes:
55+
@upbeat_vcr.use_cassette("quotation/get_candles_minutes.yaml")
56+
def test_returns_valid_candles(self) -> None:
57+
with _client() as client:
58+
result = client.quotation.get_candles_minutes(market="KRW-BTC", unit=1)
59+
60+
assert len(result) >= 1
61+
candle = result[0]
62+
assert isinstance(candle, CandleMinute)
63+
assert candle.market == "KRW-BTC"
64+
assert isinstance(candle.unit, int)
65+
assert isinstance(candle.opening_price, float)
66+
67+
68+
class TestGetCandlesSeconds:
69+
@upbeat_vcr.use_cassette("quotation/get_candles_seconds.yaml")
70+
def test_returns_valid_candles(self) -> None:
71+
with _client() as client:
72+
result = client.quotation.get_candles_seconds(market="KRW-BTC")
73+
74+
assert len(result) >= 1
75+
candle = result[0]
76+
assert isinstance(candle, CandleSecond)
77+
assert candle.market == "KRW-BTC"
78+
assert isinstance(candle.opening_price, float)
79+
80+
81+
class TestGetCandlesDays:
82+
@upbeat_vcr.use_cassette("quotation/get_candles_days.yaml")
83+
def test_returns_valid_candles(self) -> None:
84+
with _client() as client:
85+
result = client.quotation.get_candles_days(market="KRW-BTC")
86+
87+
assert len(result) >= 1
88+
candle = result[0]
89+
assert isinstance(candle, CandleDay)
90+
assert candle.market == "KRW-BTC"
91+
assert isinstance(candle.prev_closing_price, float)
92+
assert isinstance(candle.change_price, float)
93+
assert isinstance(candle.change_rate, float)
94+
95+
96+
class TestGetCandlesWeeks:
97+
@upbeat_vcr.use_cassette("quotation/get_candles_weeks.yaml")
98+
def test_returns_valid_candles(self) -> None:
99+
with _client() as client:
100+
result = client.quotation.get_candles_weeks(market="KRW-BTC")
101+
102+
assert len(result) >= 1
103+
candle = result[0]
104+
assert isinstance(candle, CandlePeriod)
105+
assert candle.market == "KRW-BTC"
106+
assert isinstance(candle.first_day_of_period, str)
107+
108+
109+
class TestGetCandlesMonths:
110+
@upbeat_vcr.use_cassette("quotation/get_candles_months.yaml")
111+
def test_returns_valid_candles(self) -> None:
112+
with _client() as client:
113+
result = client.quotation.get_candles_months(market="KRW-BTC")
114+
115+
assert len(result) >= 1
116+
candle = result[0]
117+
assert isinstance(candle, CandlePeriod)
118+
assert candle.market == "KRW-BTC"
119+
assert isinstance(candle.first_day_of_period, str)
120+
121+
122+
class TestGetCandlesYears:
123+
@upbeat_vcr.use_cassette("quotation/get_candles_years.yaml")
124+
def test_returns_valid_candles(self) -> None:
125+
with _client() as client:
126+
result = client.quotation.get_candles_years(market="KRW-BTC")
127+
128+
assert len(result) >= 1
129+
candle = result[0]
130+
assert isinstance(candle, CandlePeriod)
131+
assert candle.market == "KRW-BTC"
132+
assert isinstance(candle.first_day_of_period, str)
133+
134+
135+
# ── Orderbooks ──────────────────────────────────────────────────────────
136+
137+
138+
class TestGetOrderbooks:
139+
@upbeat_vcr.use_cassette("quotation/get_orderbooks.yaml")
140+
def test_returns_valid_orderbooks(self) -> None:
141+
with _client() as client:
142+
result = client.quotation.get_orderbooks("KRW-BTC")
143+
144+
assert len(result) >= 1
145+
ob = result[0]
146+
assert isinstance(ob, Orderbook)
147+
assert ob.market == "KRW-BTC"
148+
assert len(ob.orderbook_units) >= 1
149+
unit = ob.orderbook_units[0]
150+
assert isinstance(unit.ask_price, float)
151+
assert isinstance(unit.bid_price, float)
152+
153+
154+
class TestGetOrderbookInstruments:
155+
@upbeat_vcr.use_cassette("quotation/get_orderbook_instruments.yaml")
156+
def test_returns_valid_instruments(self) -> None:
157+
with _client() as client:
158+
result = client.quotation.get_orderbook_instruments("KRW-BTC")
159+
160+
assert len(result) >= 1
161+
inst = result[0]
162+
assert isinstance(inst, OrderbookInstrument)
163+
assert inst.market == "KRW-BTC"
164+
assert isinstance(inst.supported_levels, list)
165+
166+
167+
# ── Trades ──────────────────────────────────────────────────────────────
168+
169+
170+
class TestGetTrades:
171+
@upbeat_vcr.use_cassette("quotation/get_trades.yaml")
172+
def test_returns_valid_trades(self) -> None:
173+
with _client() as client:
174+
result = client.quotation.get_trades("KRW-BTC")
175+
176+
assert len(result) >= 1
177+
trade = result[0]
178+
assert isinstance(trade, Trade)
179+
assert trade.market == "KRW-BTC"
180+
assert trade.ask_bid in ("ASK", "BID")
181+
assert isinstance(trade.trade_price, float)
182+
assert isinstance(trade.sequential_id, int)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
interactions:
2+
- request:
3+
body: ''
4+
headers:
5+
Accept:
6+
- '*/*'
7+
Accept-Encoding:
8+
- gzip, deflate
9+
Connection:
10+
- keep-alive
11+
Host:
12+
- api.upbit.com
13+
User-Agent:
14+
- python-httpx/0.28.1
15+
method: GET
16+
uri: https://api.upbit.com/v1/candles/days?market=KRW-BTC
17+
response:
18+
body:
19+
string: |-
20+
[
21+
{
22+
"market": "KRW-BTC",
23+
"candle_date_time_utc": "2026-03-11T00:00:00",
24+
"candle_date_time_kst": "2026-03-11T09:00:00",
25+
"opening_price": 102417000.0,
26+
"high_price": 103988000.0,
27+
"low_price": 101150000.0,
28+
"trade_price": 103460000.0,
29+
"timestamp": 1773240059132,
30+
"candle_acc_trade_price": 104890599315.22821,
31+
"candle_acc_trade_volume": 1024.30999241,
32+
"prev_closing_price": 102417000.0,
33+
"change_price": 1043000.0,
34+
"change_rate": 0.0101838562
35+
}
36+
]
37+
headers:
38+
Cache-Control:
39+
- no-cache, no-store, max-age=0, must-revalidate
40+
Connection:
41+
- keep-alive
42+
Content-Type:
43+
- application/json;charset=UTF-8
44+
Date:
45+
- Wed, 11 Mar 2026 14:40:59 GMT
46+
ETag:
47+
- W/"0e2f0e810adeff5a52edede46f10a87be"
48+
Expires:
49+
- '0'
50+
Limit-By-Ip:
51+
- 'Yes'
52+
Pragma:
53+
- no-cache
54+
Remaining-Req:
55+
- group=candles; min=600; sec=7
56+
Transfer-Encoding:
57+
- chunked
58+
Vary:
59+
- origin,access-control-request-method,access-control-request-headers,accept-encoding
60+
content-length:
61+
- '455'
62+
status:
63+
code: 200
64+
message: ''
65+
version: 1

0 commit comments

Comments
 (0)