diff --git a/.gitignore b/.gitignore index 67976a7..1c14f04 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ docs/ # Serena LSP cache .serena/ + +# understand-anything knowledge graph (local analysis artifact, ~450KB) +.understand-anything/ + +# local tooling config, not part of the project +opencode.json diff --git a/AGENTS.md b/AGENTS.md index 8e38813..73723e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,6 +234,24 @@ exit 2로 하드 실패하지 않고) `checks.price_known`이 `false`가 되어 `data.top_volume`은 계좌와 달리 토큰 유무로 게이팅되지 않고 항상 시도되므로, 실패 시에는 (조회를 아예 안 하는 경우가 없어) 언제나 `null`로 나타납니다. +**v2.14.0부터 `meta.partial_failures`가 실패한 레그를 이름과 오류 코드로 +알립니다.** 위의 exit 0 + `null` 계약은 그대로입니다 — 바뀐 것은 그 `null`을 +해석할 수 있게 된 것뿐입니다. 한쪽만 실패하면: + +```json +{"ok": true, "data": {"kr": {...}, "us": null}, + "meta": {"partial_failures": {"us": "UPSTREAM_ERROR"}}} +``` + +`ok`는 여전히 `true`이고(불변식: `ok ≡ error === null`) exit code도 0입니다. +성공했거나 실패한 레그가 없으면 **키 자체가 없습니다**(`meta.fields_unmatched`와 +같은 규약). 값은 `{레그이름: 오류코드}`이고 코드는 위 표와 같은 집합입니다. +레그 이름은 계좌 계열이 `kr`/`us`, `dashboard`가 `account`/`top_volume`입니다. + +이것이 필요한 이유: `"us": null` 하나만으로는 **미국 계좌가 없는 것**과 +**조회가 실패한 것**을 구분할 수 없었습니다. 국내 계좌만 쓰는 사용자는 미국 +레그가 늘 실패하므로 이 구분이 특히 중요합니다. + ## 주문 안전장치 | 플래그 | 효과 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2941cc6..f41c745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [2.14.0] - 2026-07-20 + +공개 배포를 앞두고 한 감사에서 나온 결함들입니다. 전부 한 가지 모양이었습니다: +**확인하지 않은 것을 확인했다고 보고한다.** 도메인을 해석하지 않고 보고했고, +폐기 응답을 읽지 않고 폐기했다고 했고, 실패한 조회를 실패라고 말하지 +않았습니다. 위험한 쪽은 눈에 띄게 틀린 출력이 아니라 **그럴듯한 출력**이었기 +때문에 지금까지 드러나지 않았습니다. + +### Fixed + +- **`config show` / `auth status`가 실제 접속 도메인과 다른 값을 보고했습니다.** + `KIWOOM_DOMAIN`이 설정되면 요청은 그 도메인으로 가는데, 두 명령은 + `config.toml`의 값을 읽어 출력했습니다. `config show`로 "모의"를 확인하고 + 실거래 주문을 넣을 수 있었습니다. 이제 유효 도메인을 보고하며, + `auth status`의 `data.domain`과 `meta.env`는 항상 일치합니다. +- **미국 거래소 캐시가 `-p`로 고른 프로필의 도메인을 따르지 않았습니다.** + v2.13.0이 캐시를 도메인별로 나눴지만 `KIWOOM_DOMAIN` 축만 다뤘고, CLI + `--profile` 축은 놓쳤습니다. 도메인이 다른 프로필이 둘 이상 있고 `-p`로 + 전환하면, **모의투자에서 학습한 거래소가 실주문의 `stex_tp`로 나갈 수 + 있었습니다.** 근본 원인은 `config.resolve_profile()`이 자기 docstring이 + 선언한 우선순위(`--profile` 1순위)를 구현하지 않은 것이라, 호출부가 아니라 + 그 함수를 고쳤습니다. 캐시 세대를 `us_exchanges2-*`로 올려 오염 가능성이 + 있는 기존 파일은 읽지 않습니다(최악의 비용은 `usa10098` 재조회 1회). +- **`auth logout`이 폐기 응답을 확인하지 않고 성공을 보고했습니다.** HTTP + 4xx/5xx와 `return_code` 8015/8016이 전부 성공으로 처리됐고, 그 뒤 로컬 + 토큰까지 지워 **서버에 살아 있는 토큰을 두 번 다시 폐기할 수 없게** 만들었습니다. + +### Added + +- `meta.partial_failures` — 통합 명령에서 한쪽 레그만 실패했을 때 그 이름과 + 오류 코드를 알립니다(`{"us": "UPSTREAM_ERROR"}`). `data.us: null`이 "미국 + 계좌가 없다"인지 "조회가 실패했다"인지 구분할 수 없던 문제를 해결합니다. + `ok`/exit code/`data` 모양은 그대로이므로 기존 소비자는 영향받지 않습니다. + 실패한 레그가 없으면 키 자체가 없습니다. +- `auth logout --force` — 서버 폐기가 실패해도 로컬 토큰을 지웁니다(서버 도달 + 불가 시 탈출구). 이때 `revoked`는 `false`로 보고됩니다. +- `config profiles -f json`에 `domain_override` — `KIWOOM_DOMAIN`이 모든 + 프로필을 덮고 있으면 그 값을, 아니면 `null`. 목록의 `domain`은 설정값을 + 그대로 유지합니다(행마다 유효 도메인을 계산하면 전부 같은 값이 되어 설정 + 정보가 사라지기 때문). + +### Breaking + +- `config show` / `auth status`의 `data.domain`이 `KIWOOM_DOMAIN` 설정 시 + 다른 값을 냅니다. 이것이 수정의 요지입니다 — 종전 값은 틀린 값이었습니다. +- `auth logout`이 상단 폐기 실패 시 exit 2로 끝나고 로컬 토큰을 지우지 + 않습니다(종전에는 exit 0 + 삭제). +- `KiwoomClient.revoke_token()`이 `force` 매개변수를 받고 반환 dict에 + `revoked` 키가 추가됩니다. +- 미국 거래소 캐시 파일명이 `us_exchanges-*.json` → `us_exchanges2-*.json`. + 기존 파일은 마이그레이션하지 않고 읽지 않습니다. +- `config profiles -f json`의 각 행에 `domain_override` 키가 추가됩니다. + ## [2.13.0] - 2026-07-20 2026-07-18 감사의 마지막 트랜치입니다. WebSocket 스트리밍의 실패 감지와 종료 diff --git a/README.md b/README.md index 9ac2ac2..1e44c3a 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,18 @@ kiwoom auth login # 3. 끝. 이후 모든 명령은 프롬프트 없이 동작 kiwoom stock info 005930 + +# 토큰 폐기 +kiwoom auth logout +kiwoom auth logout --force # 서버 폐기가 실패해도 로컬 토큰은 지웁니다 ``` +`auth logout`은 서버 폐기 응답을 확인한 뒤에만 성공을 보고합니다. 폐기가 +실패하면 exit 2로 끝나고 **로컬 토큰을 지우지 않습니다** — 지워버리면 서버에 +살아 있는 토큰을 두 번 다시 폐기할 수 없기 때문입니다. 서버에 도달할 수 없어 +로컬 정리가 막히는 상황에서만 `--force`를 쓰세요. 이때도 `revoked`는 `false`로 +보고됩니다(확인하지 않은 것을 성공이라 하지 않습니다). + ### 모의투자 vs 실거래 `config setup` 시 도메인을 선택합니다. 이후 변경: @@ -220,7 +230,8 @@ kiwoom -p isa config set domain mock # 특정 프로필 # 프로필 전환 / 목록 kiwoom config use isa -kiwoom config profiles +kiwoom config profiles # -f json이면 domain_override로 + # KIWOOM_DOMAIN 덮어쓰기를 함께 알립니다 # 특정 프로필로 사용 kiwoom -p isa account balance diff --git a/kiwoom_cli/__init__.py b/kiwoom_cli/__init__.py index 9535148..217a88c 100644 --- a/kiwoom_cli/__init__.py +++ b/kiwoom_cli/__init__.py @@ -1,3 +1,3 @@ """Kiwoom Securities REST API CLI.""" -__version__ = "2.13.0" +__version__ = "2.14.0" diff --git a/kiwoom_cli/client.py b/kiwoom_cli/client.py index 9726cb4..b6a0156 100644 --- a/kiwoom_cli/client.py +++ b/kiwoom_cli/client.py @@ -37,7 +37,9 @@ def __init__(self, domain: str | None = None, token: str | None = None, profile: if profile is None: ctx = click.get_current_context(silent=True) if ctx and ctx.obj: - profile = ctx.obj.get("profile") + # 루트가 해석해 둔 값을 우선 읽는다. 없으면(루트 콜백을 거치지 + # 않는 직접 사용) 원시 플래그로 폴백한다. + profile = ctx.obj.get("resolved_profile") or ctx.obj.get("profile") self.profile = profile self.domain = domain or config.get_domain(profile=profile) self.token = token or auth.load_token(profile=profile) @@ -218,11 +220,16 @@ def issue_token(self, appkey: str | None = None, secretkey: str | None = None) - self.token = token return token - def revoke_token(self) -> dict[str, Any]: + def revoke_token(self, force: bool = False) -> dict[str, Any]: """Revoke the current access token via au10002. 어느 토큰을 폐기했고 키체인 항목을 지웠는지를 돌려준다 - (`{"token_source": "env"|"keychain", "keychain_token_deleted": bool}`). + (`{"revoked": bool, "token_source": "env"|"keychain", + "keychain_token_deleted": bool}`). + + force=True는 상단 폐기가 실패해도 로컬 정리를 진행한다(서버 도달 불가로 + 영영 정리를 못 하는 상황의 탈출구). 이때도 revoked를 True로 만들지 + 않는다 — 확인하지 않은 것을 성공이라 보고하는 것이 애초의 결함이었다. auth.load_token은 KIWOOM_TOKEN을 키체인보다 먼저 반환하므로, env 토큰을 폐기해 놓고 키체인의 {profile}:token을 지우면 **폐기한 적 없는 다른 살아 @@ -236,11 +243,26 @@ def revoke_token(self) -> dict[str, Any]: if not token: raise click.ClickException("No token to revoke.") - self._http.post( + resp = self._http.post( "/oauth2/revoke", headers={"content-type": CONTENT_TYPE, "api-id": "au10002"}, json={"appkey": ak, "secretkey": sk, "token": token}, ) + revoked = True + try: + # issue_token과 같은 확인. 종전에는 응답을 이름에 묶지도 않아 + # HTTP 4xx/5xx와 return_code 8015/8016이 전부 성공으로 보고됐고, + # 그 뒤 로컬 토큰까지 지워 재폐기를 불가능하게 만들었다. + resp.raise_for_status() + data = resp.json() + rc = data.get("return_code") + if rc is not None and str(rc) != "0": + raise KiwoomAPIError(rc, data.get("return_msg", "토큰 폐기 실패")) + except (httpx.HTTPError, KiwoomAPIError): + if not force: + raise + revoked = False + from_env = bool(env_token) and token == env_token # 키체인이 방금 폐기한 토큰을 들고 있으면 출처와 무관하게 지운다 # (죽은 토큰을 남기면 auth status가 유효한 것처럼 보고한다). @@ -250,6 +272,7 @@ def revoke_token(self) -> dict[str, Any]: auth.delete_token(profile=self.profile) self.token = None return { + "revoked": revoked, "token_source": "env" if from_env else "keychain", "keychain_token_deleted": bool(delete_keychain and keychain_token is not None), } diff --git a/kiwoom_cli/commands/account.py b/kiwoom_cli/commands/account.py index 4b05723..9b813b4 100644 --- a/kiwoom_cli/commands/account.py +++ b/kiwoom_cli/commands/account.py @@ -86,13 +86,20 @@ def _unified_structured(market: str, kr_fetch, us_fetch) -> bool: fmt = _get_format() if fmt != "json": return False + from .. import envelope + kr_data = us_data = None + # 실패한 레그를 코드와 함께 모은다 — null이 '그 계좌가 없다'인지 + # '조회가 깨졌다'인지 구별할 방법이 이것뿐이다. 코드는 지어내지 않고 + # envelope.classify가 쓰는 것과 같은 분류를 쓴다. + failures: dict[str, str] = {} if market in ("all", "kr"): try: kr_data = kr_fetch() except KiwoomAPIError as e: if market == "kr": raise + failures["kr"] = envelope.classify(upstream_code=e.code)[0] err_console.print(f"[dim]국내 조회 실패: {e}[/]") if market in ("all", "us"): try: @@ -100,10 +107,11 @@ def _unified_structured(market: str, kr_fetch, us_fetch) -> bool: except KiwoomAPIError as e: if market == "us": raise + failures["us"] = envelope.classify(upstream_code=e.code)[0] err_console.print(f"[dim]미국 조회 실패: {e}[/]") if market == "all" and kr_data is None and us_data is None: fail_api("국내/미국 조회가 모두 실패했습니다.") - _output_json({"kr": kr_data, "us": us_data}) + _output_json({"kr": kr_data, "us": us_data}, partial_failures=failures or None) return True @@ -137,7 +145,10 @@ def account_list(): @click.option("--delist", "qry_tp", default="all", type=HumanChoice(DELIST_QRY), help="상장폐지조회구분 (all=전체, exclude=제외)") def balance(market: str, dmst_stex_tp: str, qry_tp: str): """계좌 평가현황 — 국내+미국 통합. (kt00004 + ust21070)""" + from .. import envelope + kr_data = us_data = None + failures: dict[str, str] = {} with KiwoomClient() as c: if market in ("all", "kr"): try: @@ -145,6 +156,7 @@ def balance(market: str, dmst_stex_tp: str, qry_tp: str): except KiwoomAPIError as e: if market == "kr": raise + failures["kr"] = envelope.classify(upstream_code=e.code)[0] err_console.print(f"[dim]국내 잔고 조회 실패: {e}[/]") if market in ("all", "us"): try: @@ -152,13 +164,14 @@ def balance(market: str, dmst_stex_tp: str, qry_tp: str): except KiwoomAPIError as e: if market == "us": raise + failures["us"] = envelope.classify(upstream_code=e.code)[0] err_console.print(f"[dim]미국 잔고 조회 실패 (미국주식 미개설 계좌일 수 있음): {e}[/]") if market == "all" and kr_data is None and us_data is None: fail_api("국내/미국 잔고 조회가 모두 실패했습니다.") if market == "kr": print_account_eval(kr_data or {}) else: - print_unified_balance(kr_data, us_data) + print_unified_balance(kr_data, us_data, partial_failures=failures or None) @account.command("deposit") diff --git a/kiwoom_cli/commands/dashboard.py b/kiwoom_cli/commands/dashboard.py index a32fdf7..7269821 100644 --- a/kiwoom_cli/commands/dashboard.py +++ b/kiwoom_cli/commands/dashboard.py @@ -9,6 +9,7 @@ from rich.table import Table from rich.text import Text +from .. import envelope from ..client import KiwoomClient, KiwoomAPIError from ..formatters import ( _calc_eval_pl, @@ -80,6 +81,9 @@ def dashboard(): movers_items: list[dict[str, Any]] = [] acct_failed = False movers_failed = False + # 문서화된 3상태를 유지한다: 키 없음 = 시도조차 안 함(토큰 없음), + # null = 시도했고 실패. partial_failures에는 후자만 들어간다. + failures: dict[str, str] = {} with KiwoomClient() as c: # Account balance -- skip gracefully when not logged in @@ -89,6 +93,7 @@ def dashboard(): except KiwoomAPIError as e: acct_data = None acct_failed = True + failures["account"] = envelope.classify(upstream_code=e.code)[0] err_console.print(f"[dim]계좌 조회 실패: {e}[/]") else: acct_data = None @@ -127,6 +132,7 @@ def dashboard(): except KiwoomAPIError as e: movers_data = None movers_failed = True + failures["top_volume"] = envelope.classify(upstream_code=e.code)[0] err_console.print(f"[dim]거래량 상위 조회 실패: {e}[/]") if acct_failed and movers_failed: @@ -145,7 +151,8 @@ def dashboard(): combined["top_volume"] = movers_items elif movers_data: combined["top_volume"] = movers_data - print_generic_table(combined, title="대시보드") + print_generic_table(combined, title="대시보드", + partial_failures=failures or None) return # ── Rich table output ───────────────────────────────── diff --git a/kiwoom_cli/commands/us/detect.py b/kiwoom_cli/commands/us/detect.py index d0ddee6..422507f 100644 --- a/kiwoom_cli/commands/us/detect.py +++ b/kiwoom_cli/commands/us/detect.py @@ -10,7 +10,11 @@ from ._constants import KR_EXCHANGE, US_EXCHANGE # 거래소 캐시는 도메인(prod/mock)별로 파일이 갈린다 — 아래 _cache_file 주석 참고. -_CACHE_PREFIX = "us_exchanges" +# 세대 표식 "2": v2.13.0의 도메인 분리가 -p 축을 놓쳐, prod에서 학습한 거래소가 +# us_exchanges-mock.json에 기록됐을 수 있다. 그 파일들은 마이그레이션하지 않고 +# 읽지 않는다 (v2.12 평문 형식을 폐기한 선례와 동일). 최악의 비용은 usa10098 +# 재조회 1회다. +_CACHE_PREFIX = "us_exchanges2" _CACHE_TTL_SEC = 24 * 60 * 60 @@ -33,15 +37,18 @@ class UsExchangeError(Exception): def _cache_file(): - """도메인별 캐시 파일 경로 (us_exchanges-prod.json / us_exchanges-mock.json). + """도메인별 캐시 파일 경로 (us_exchanges2-prod.json / us_exchanges2-mock.json). 파일이 하나뿐이면 모의투자에서 학습한 거래소가 실거래 주문의 stex_tp로 그대로 나간다 — 잘못된 거래소로 실주문이 나가는 경로다. 프로필로는 나누지 않는다. 'NVDA가 나스닥 상장'은 계좌가 아니라 시장의 - 사실이라, 같은 도메인의 프로필끼리는 공유해도 틀릴 여지가 없다. 반면 - 도메인은 응답을 주는 상단 서버 자체가 다르다. 프로필별 도메인 차이는 - get_domain_key(profile 해석 포함)가 이미 흡수한다. + 사실이라, 같은 도메인의 프로필끼리는 공유해도 틀릴 여지가 없다. 도메인은 + 응답을 주는 서버 자체가 다르므로 반드시 나눈다. + + get_domain_key()를 인자 없이 호출하지만, resolve_profile이 Click 컨텍스트의 + --profile을 읽으므로 -p로 고른 프로필의 도메인이 반영된다. v2.13.0까지는 + 그렇지 않아 모의 학습값이 실주문에 실릴 수 있었다. """ return config.CACHE_DIR / f"{_CACHE_PREFIX}-{config.get_domain_key()}.json" diff --git a/kiwoom_cli/config.py b/kiwoom_cli/config.py index 8f062ac..fa91db8 100644 --- a/kiwoom_cli/config.py +++ b/kiwoom_cli/config.py @@ -160,10 +160,26 @@ def save_config(cfg: dict) -> None: def resolve_profile(profile: str | None = None) -> str: """Resolve the active profile name. - Priority: explicit arg > KIWOOM_PROFILE env > general.default_profile > "default" + Priority: explicit arg > CLI --profile (Click ctx) > KIWOOM_PROFILE env + > general.default_profile > "default" + + **CLI 플래그를 여기서 읽는 이유.** 종전에는 이 함수가 뒤의 셋만 구현하고 + `--profile`은 호출자가 넘겨줄 때만 반영됐다. 그래서 ctx.obj["profile"]를 + 읽는 자리가 리포 전역 10곳으로 흩어졌고, 넘기는 것을 잊은 호출부 + (us/detect.py)가 조용히 다른 프로필의 도메인으로 캐시를 갈랐다 — 모의에서 + 학습한 거래소가 실주문에 실릴 수 있었다. 우선순위 자체는 종전 docstring이 + 이미 선언하던 것이라 계약 변경이 아니라 계약 이행이다. + + Click 컨텍스트가 없어도(테스트·라이브러리 사용) 동작해야 하므로 + silent=True로 조회하고 obj가 None/빈 dict인 경우를 모두 견딘다. """ if profile: return profile + ctx = click.get_current_context(silent=True) + if ctx is not None and isinstance(ctx.obj, dict): + cli_profile = ctx.obj.get("profile") + if cli_profile: + return cli_profile env = os.environ.get("KIWOOM_PROFILE") if env: return env diff --git a/kiwoom_cli/envelope.py b/kiwoom_cli/envelope.py index 10ce3e6..aeeba52 100644 --- a/kiwoom_cli/envelope.py +++ b/kiwoom_cli/envelope.py @@ -98,8 +98,10 @@ def build_meta() -> dict[str, Any]: ctx = click.get_current_context(silent=True) obj = ctx.obj if ctx is not None and isinstance(ctx.obj, dict) else {} try: - profile = config.resolve_profile(obj.get("profile")) - env = config.get_domain_key(profile) + # 루트가 해석해 둔 값을 우선 읽는다 — 재계산하면 "우연히 같은 값"이지 + # "클라이언트가 실제로 쓴 값"이 아니다. + profile = obj.get("resolved_profile") or config.resolve_profile(obj.get("profile")) + env = obj["domain_key"] if "domain_key" in obj else config.get_domain_key(profile) except click.ClickException as e: # config.toml이 손상된 경우(config.load_config()가 재발생시키는 # NOT_CONFIGURED ClickException) meta 구성이 load_config()를 다시 호출해 @@ -173,8 +175,13 @@ def _collect_matched(data: Any, fields: list[str], found: set[str]) -> None: _collect_matched(v, fields, found) -def emit(data: Any = None, *, error: dict[str, Any] | None = None) -> None: - """Envelope 전체를 단일 JSON 문서로 stdout에 출력.""" +def emit(data: Any = None, *, error: dict[str, Any] | None = None, + partial_failures: dict[str, str] | None = None) -> None: + """Envelope 전체를 단일 JSON 문서로 stdout에 출력. + + partial_failures: 여러 레그를 합치는 명령(계좌 kr/us 등)에서 일부만 + 실패했을 때 {"레그이름": "ERROR_CODE"}. 비어 있으면 키를 만들지 않는다. + """ ctx = click.get_current_context(silent=True) obj = ctx.obj if ctx is not None and isinstance(ctx.obj, dict) else {} fields = obj.get("fields") @@ -187,6 +194,12 @@ def emit(data: Any = None, *, error: dict[str, Any] | None = None) -> None: meta = build_meta() if unmatched: meta["fields_unmatched"] = unmatched + if partial_failures: + # 조건부 meta 키. meta.fields_unmatched와 같은 선례를 따른다. + # ok/exit code/data 모양을 건드리지 않으므로 AGENTS.md의 "한쪽만 + # 실패하면 exit 0 + 해당 키 null" 약속을 깨지 않는다. null이 '그 + # 계좌가 없다'인지 '조회가 깨졌다'인지 구별하기 위한 것이다. + meta["partial_failures"] = partial_failures doc = { "ok": error is None, "schema": SCHEMA, diff --git a/kiwoom_cli/formatters.py b/kiwoom_cli/formatters.py index 4c4ef60..19b6a2b 100644 --- a/kiwoom_cli/formatters.py +++ b/kiwoom_cli/formatters.py @@ -60,12 +60,15 @@ def fail_api(message: str, *, code: str = "UPSTREAM_ERROR") -> NoReturn: raise SystemExit(2) -def _output_json(data: Any) -> None: +def _output_json(data: Any, partial_failures: dict[str, str] | None = None) -> None: """Write a Kiwoom API response as an enveloped JSON document to stdout. data는 정규화(normalize_record)된 타입 있는 필드로 변환되고, 원본은 data.raw에 보존됩니다. API 응답이 아닌 페이로드(dry-run, validate 등)는 envelope.emit을 직접 호출하므로 이 경로를 타지 않습니다. + + partial_failures: 여러 레그를 합치는 응답에서 실패한 레그와 그 오류 코드. + data의 모양은 건드리지 않고 meta에만 실린다. """ from . import normalize # 순환 import 회피 (normalize가 필드 분류를 여기서 가져감) @@ -73,14 +76,14 @@ def _output_json(data: Any) -> None: clean = {k: v for k, v in data.items() if k not in ("return_code", "return_msg")} doc = normalize.normalize_record(clean) doc["raw"] = clean - envelope.emit(data=doc) + envelope.emit(data=doc, partial_failures=partial_failures) elif isinstance(data, list): envelope.emit(data={ "items": [normalize.normalize_record(x) if isinstance(x, dict) else x for x in data], "raw": data, - }) + }, partial_failures=partial_failures) else: - envelope.emit(data=data) + envelope.emit(data=data, partial_failures=partial_failures) def _output_csv(rows: list[dict], keys: list[str] | None = None) -> None: @@ -953,12 +956,13 @@ def _get_label(key: str) -> str: return key -def print_generic_table(data: dict[str, Any] | list, title: str = "결과") -> None: +def print_generic_table(data: dict[str, Any] | list, title: str = "결과", + partial_failures: dict[str, str] | None = None) -> None: """Generic formatter for any API response. Respects --format option.""" fmt = _get_format() if fmt == "json": - _output_json(data) + _output_json(data, partial_failures=partial_failures) return if fmt == "csv": if isinstance(data, list): @@ -1056,11 +1060,12 @@ def print_deposit(data: dict[str, Any]) -> None: console.print(t) -def print_unified_balance(kr_data: dict[str, Any] | None, us_data: dict[str, Any] | None) -> None: +def print_unified_balance(kr_data: dict[str, Any] | None, us_data: dict[str, Any] | None, + partial_failures: dict[str, str] | None = None) -> None: """국내(kt00004) + 미국(ust21070) 통합 계좌 평가현황.""" fmt = _get_format() if fmt == "json": - _output_json({"kr": kr_data, "us": us_data}) + _output_json({"kr": kr_data, "us": us_data}, partial_failures=partial_failures) return if fmt == "csv": keys = [ diff --git a/kiwoom_cli/main.py b/kiwoom_cli/main.py index 917cdfe..1e03d80 100644 --- a/kiwoom_cli/main.py +++ b/kiwoom_cli/main.py @@ -184,6 +184,19 @@ def cli(ctx, output_format, profile, fields, no_color, next_key, all_pages): "영문자/숫자/하이픈/언더스코어 1~64자만 허용됩니다." ) + # 루트에서 한 번만 해석해 저장한다. KiwoomClient와 envelope.build_meta가 + # 각자 ctx.obj["profile"]를 다시 해석하면 두 값이 갈릴 수 있고, meta.env는 + # "클라이언트가 실제로 쓴 도메인"이어야지 "같은 입력으로 다시 계산한 값"이면 + # 안 된다 (AGENTS.md가 주문 전 meta.env 확인을 지시한다). + ctx.obj["resolved_profile"] = resolved_profile + try: + ctx.obj["domain_key"] = config.get_domain_key(resolved_profile) + except click.ClickException: + # config.toml 손상. 위 resolved_profile 폴백과 같은 이유로 여기서 죽지 + # 않는다. env를 지어내지 않고 None으로 둔다 — envelope.build_meta의 + # 기존 정책과 동일하다. + ctx.obj["domain_key"] = None + if next_key and all_pages: raise click.UsageError("--next-key와 --all-pages는 함께 사용할 수 없습니다.") ctx.obj["next_key"] = next_key @@ -329,7 +342,10 @@ def config_show(ctx): envelope.emit(data={ "profile": profile, "config_file": str(config.CONFIG_FILE), - "domain": profile_cfg.get("domain", "mock"), + # 설정 파일 값이 아니라 실제 접속 도메인 — KIWOOM_DOMAIN이 이것을 + # 덮는다. 종전에는 config show가 '모의'라고 답하는 동안 요청은 + # 실서버로 갔다. + "domain": config.get_domain_key(profile), "configured": configured, "account": profile_cfg.get("account", ""), "token_storage": token_storage, @@ -337,7 +353,7 @@ def config_show(ctx): return human(f" 프로필: [bold]{profile}[/]") human(f" 설정 파일: {config.CONFIG_FILE}") - human(f" 도메인: {profile_cfg.get('domain', 'mock')}") + human(f" 도메인: {config.get_domain_key(profile)}") human(f" App Key: {'[dim]설정됨 (키체인)[/]' if configured else '(미설정)'}") human(f" 계좌번호: {profile_cfg.get('account', '(미설정)')}") human(f" 토큰 저장: {'환경변수 (KIWOOM_TOKEN)' if token_storage == 'env' else 'OS 키체인'}") @@ -399,11 +415,17 @@ def config_profiles(): cfg = config.load_config() profiles = cfg.get("profiles", {}) default = config.get_default_profile() + override = os.environ.get("KIWOOM_DOMAIN") + override = override if override in config.DOMAINS else None if _get_format() == "json": envelope.emit(data=[ { "name": name, "domain": settings.get("domain", "mock"), + # KIWOOM_DOMAIN은 모든 프로필을 한꺼번에 덮으므로, 행마다 + # 유효 도메인을 계산하면 전부 같은 값이 되어 설정 정보가 + # 사라진다. 설정값은 그대로 두고 덮어쓰기를 별도로 알린다. + "domain_override": override, "account": settings.get("account", ""), "default": name == default, } @@ -414,6 +436,8 @@ def config_profiles(): human("[yellow]등록된 프로필이 없습니다.[/]") return human(f" 현재 프로필: [bold green]{default}[/]") + if override: + human(f" [yellow]KIWOOM_DOMAIN={override} 가 모든 프로필의 도메인을 덮고 있습니다.[/]") human("") for name, settings in profiles.items(): marker = " [green]*[/]" if name == default else " " @@ -498,24 +522,32 @@ def auth_login(ctx): @auth_cmd.command("logout") +@click.option("--force", is_flag=True, + help="서버 폐기에 실패해도 로컬 토큰을 지웁니다 (서버 도달 불가 시 탈출구).") @click.pass_context -def auth_logout(ctx): +def auth_logout(ctx, force): """접근토큰 폐기.""" profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None) if not config.is_configured(profile): _fail_not_configured() # 폐기 실패(KiwoomAPIError)는 전역 핸들러가 envelope/exit 2로 처리 with KiwoomClient() as c: - outcome = c.revoke_token() + outcome = c.revoke_token(force=force) if _get_format() == "json": envelope.emit(data={ "profile": profile, - "revoked": True, + "revoked": outcome["revoked"], "token_source": outcome["token_source"], "keychain_token_deleted": outcome["keychain_token_deleted"], }) return - human("[green]토큰 폐기 완료.[/]") + if outcome["revoked"]: + human("[green]토큰 폐기 완료.[/]") + else: + # --force 경로. 서버 폐기는 실패했고 로컬만 정리했다 — 성공이라 + # 뭉뚱그리면 사용자는 서버에 살아 있는 토큰을 모르고 지나간다. + human("[yellow]서버 폐기 실패.[/] 로컬 토큰만 삭제했습니다 (--force).") + human(" [yellow]서버에는 토큰이 아직 유효할 수 있습니다.[/]") if outcome["token_source"] == "env": # 폐기된 것은 env 토큰이다. 예전에는 여기서 키체인 토큰까지 지워 # 폐기한 적 없는 토큰을 폐기 불가능하게 만들었다 — 무엇을 하지 @@ -541,10 +573,10 @@ def auth_status(ctx): if token is not None: token_source = "env" if os.environ.get("KIWOOM_TOKEN") else "keyring" if _get_format() == "json": - cfg = config.load_config() envelope.emit(data={ "profile": profile, - "domain": cfg.get("profiles", {}).get(profile, {}).get("domain", "mock"), + # config show와 같은 이유로 실제 접속 도메인을 보고한다. + "domain": config.get_domain_key(profile), "configured": configured, "has_token": token is not None, "token_source": token_source, diff --git a/tests/test_auth_cli.py b/tests/test_auth_cli.py index 63de24b..2d6317a 100644 --- a/tests/test_auth_cli.py +++ b/tests/test_auth_cli.py @@ -411,3 +411,96 @@ def test_logout_without_env_token_deletes_keychain_token(logout_ready): assert logout_ready["token"] == "keychain-token" assert keyring.get_password(config.KEYRING_SERVICE, "default:token") is None assert "키체인" in result.output + + +@pytest.fixture +def revoke_fails(monkeypatch): + """revoke 응답이 상단 실패(return_code 8015)를 돌려주도록 만든다. + + logout_ready가 돌려주는 dict는 '전송된 body'라는 뜻이므로 거기에 응답 + 스텁을 섞지 않고, 여기서 post를 따로 갈아끼운다. + """ + from kiwoom_cli import client as client_mod + + class _FailResp: + status_code = 200 + + def json(self): + return {"return_code": 8015, "return_msg": "폐기 실패"} + + def raise_for_status(self): + return None + + monkeypatch.setattr(client_mod.httpx.Client, "post", lambda self, url, **kw: _FailResp()) + + +class TestLogoutVerifiesRevoke: + """revoke 응답을 확인하지 않고 revoked:true를 보고한 뒤 로컬 토큰을 + 지웠다. 서버에는 토큰이 살아 있는데 로컬 사본이 없어 두 번 다시 폐기할 수 + 없는 상태가 된다.""" + + def test_upstream_failure_exits_2_and_keeps_token(self, logout_ready, revoke_fails): + import json as _json + + keyring.set_password(config.KEYRING_SERVICE, "default:token", "keychain-token") + result = CliRunner().invoke(cli, ["-f", "json", "auth", "logout"]) + assert result.exit_code == 2, result.output + doc = _json.loads(result.stdout) + assert doc["ok"] is False + assert keyring.get_password(config.KEYRING_SERVICE, "default:token") is not None, \ + "폐기에 실패했는데 로컬 토큰을 지웠다 — 재시도가 영영 불가능해진다" + + def test_force_deletes_local_token_despite_failure(self, logout_ready, revoke_fails): + """서버 도달 불가로 영영 로컬 정리를 못 하는 상황의 탈출구.""" + keyring.set_password(config.KEYRING_SERVICE, "default:token", "keychain-token") + result = CliRunner().invoke(cli, ["auth", "logout", "--force"]) + assert result.exit_code == 0, result.output + assert keyring.get_password(config.KEYRING_SERVICE, "default:token") is None + + def test_http_error_also_blocks_deletion(self, logout_ready, monkeypatch): + """return_code뿐 아니라 HTTP 4xx/5xx도 성공으로 보고되면 안 된다.""" + import httpx + + from kiwoom_cli import client as client_mod + + class _HttpErrResp: + status_code = 500 + + def json(self): + return {} + + def raise_for_status(self): + raise httpx.HTTPStatusError("500", request=None, response=None) + + monkeypatch.setattr(client_mod.httpx.Client, "post", + lambda self, url, **kw: _HttpErrResp()) + keyring.set_password(config.KEYRING_SERVICE, "default:token", "keychain-token") + result = CliRunner().invoke(cli, ["auth", "logout"]) + assert result.exit_code != 0 + assert keyring.get_password(config.KEYRING_SERVICE, "default:token") is not None + + def test_success_path_unchanged(self, logout_ready): + import json as _json + + keyring.set_password(config.KEYRING_SERVICE, "default:token", "keychain-token") + result = CliRunner().invoke(cli, ["-f", "json", "auth", "logout"]) + assert result.exit_code == 0, result.output + doc = _json.loads(result.stdout) + assert doc["data"]["revoked"] is True + + +def test_force_logout_json_reports_revoked_false(logout_ready, revoke_fails): + """--force는 로컬 정리를 허용하는 탈출구일 뿐, 폐기 성공의 선언이 아니다. + + 여기서 revoked:true를 내보내면 이 릴리스가 없애려는 결함(확인하지 않은 + 것을 성공이라 보고)을 --force 경로에 그대로 남기는 셈이다. + """ + import json as _json + + keyring.set_password(config.KEYRING_SERVICE, "default:token", "keychain-token") + result = CliRunner().invoke(cli, ["-f", "json", "auth", "logout", "--force"]) + assert result.exit_code == 0, result.output + doc = _json.loads(result.stdout) + assert doc["data"]["revoked"] is False, \ + "서버 폐기에 실패했는데 revoked:true로 보고했다" + assert keyring.get_password(config.KEYRING_SERVICE, "default:token") is None diff --git a/tests/test_cli.py b/tests/test_cli.py index b62d299..9ad4e10 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -299,3 +299,128 @@ def test_non_json_response_returns_upstream_error_envelope(runner, monkeypatch, doc = json.loads(result.output) assert doc["ok"] is False assert doc["error"]["code"] == "UPSTREAM_ERROR" + + +class TestDomainDisplayTruthfulness: + """KIWOOM_DOMAIN이 설정되면 요청은 그 도메인으로 가는데 표시 명령은 + config.toml 값을 읽어 다른 도메인을 출력했다. 사용자가 config show로 + '모의'를 확인하고 실거래 주문을 넣을 수 있었다.""" + + @pytest.fixture + def two_domains(self, monkeypatch, tmp_path): + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "default"\n' + '[profiles.default]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.setattr("kiwoom_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + return cfg + + def test_config_show_reports_env_override(self, runner, two_domains, monkeypatch): + monkeypatch.setenv("KIWOOM_DOMAIN", "prod") + doc = json.loads(runner.invoke(cli, ["-f", "json", "config", "show"]).stdout) + assert doc["data"]["domain"] == "prod", "config show가 실제 접속 도메인과 다른 값을 출력한다" + + def test_auth_status_reports_env_override(self, runner, two_domains, monkeypatch): + monkeypatch.setenv("KIWOOM_DOMAIN", "prod") + doc = json.loads(runner.invoke(cli, ["-f", "json", "auth", "status"]).stdout) + assert doc["data"]["domain"] == "prod" + + def test_auth_status_domain_matches_meta_env(self, runner, two_domains, monkeypatch): + """같은 문서 안에서 data.domain과 meta.env가 모순되면 안 된다.""" + monkeypatch.setenv("KIWOOM_DOMAIN", "prod") + doc = json.loads(runner.invoke(cli, ["-f", "json", "auth", "status"]).stdout) + assert doc["data"]["domain"] == doc["meta"]["env"] + + def test_config_profiles_keeps_configured_value_and_marks_override( + self, runner, two_domains, monkeypatch + ): + """목록은 '설정된 값'을 보여주는 것이 맞다 — 모든 행을 prod로 덮어쓰면 + 오히려 정보가 사라진다. 대신 override를 명시한다.""" + monkeypatch.setenv("KIWOOM_DOMAIN", "prod") + doc = json.loads(runner.invoke(cli, ["-f", "json", "config", "profiles"]).stdout) + row = doc["data"][0] + assert row["domain"] == "mock" + assert row["domain_override"] == "prod" + + def test_no_override_means_null(self, runner, two_domains, monkeypatch): + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + doc = json.loads(runner.invoke(cli, ["-f", "json", "config", "profiles"]).stdout) + assert doc["data"][0]["domain_override"] is None + + +class TestResolutionIsStashedNotRecomputed: + """루트에서 한 번 해석한 값을 공유하는 것과, 소비자가 각자 다시 + 계산하는 것의 관측 가능한 차이를 고정한다. 값이 우연히 같은 것과 + 구조적으로 같은 것은 다르다.""" + + def test_meta_env_describes_the_run_not_the_post_run_state( + self, runner, monkeypatch, tmp_path + ): + """config set domain prod는 config.toml을 바꾼 뒤 envelope을 낸다. + + meta.env가 그 시점에 파일을 다시 읽으면 '이번 실행에서 한 번도 + 유효한 적 없는 도메인'을 보고하게 된다. meta.env는 이 명령이 어떤 + 환경에서 돌았는지를 말해야 한다 — AGENTS.md가 주문 전 meta.env로 + prod/mock을 확인하라고 지시하기 때문이다. + """ + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "default"\n' + '[profiles.default]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.setattr("kiwoom_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + + doc = json.loads(runner.invoke( + cli, ["-f", "json", "config", "set", "domain", "prod"]).stdout) + assert doc["data"]["value"] == "prod" + assert doc["meta"]["env"] == "mock", ( + "meta.env가 방금 쓴 값을 다시 읽었다 — 이번 실행은 mock에서 돌았다" + ) + + def test_client_profile_is_the_resolved_name_under_env_var( + self, monkeypatch, tmp_path + ): + """KIWOOM_PROFILE만 있고 -p가 없으면 ctx.obj['profile']는 None이다. + + KiwoomClient가 그 원시 플래그를 읽으면 self.profile이 None이 되어 + meta.profile과 갈린다. 루트가 해석해 둔 이름을 읽어야 한다. + """ + import click as _click + + from kiwoom_cli import envelope + from kiwoom_cli.client import KiwoomClient + + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "default"\n' + '[profiles.envprof]\ndomain = "prod"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.setenv("KIWOOM_PROFILE", "envprof") + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + monkeypatch.setattr("kiwoom_cli.auth.load_token", lambda profile=None: "tok") + + # 루트 콜백이 만들어 두는 상태를 그대로 재현한다: -p 미지정이므로 + # profile은 None, resolved_profile은 env가 해석된 이름. + obj = {"profile": None, "resolved_profile": "envprof", "domain_key": "prod"} + with _click.Context(_click.Command("x"), obj=obj): + with KiwoomClient() as c: + assert c.profile == "envprof", \ + "클라이언트가 원시 플래그(None)를 읽어 프로필을 잃었다" + assert c.profile == envelope.build_meta()["profile"] + + def test_config_show_table_mode_also_reports_effective_domain( + self, runner, monkeypatch, tmp_path + ): + """json만 고치고 사람이 읽는 출력을 놓치면, 정작 눈으로 확인하는 + 사용자가 계속 틀린 도메인을 본다.""" + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "default"\n' + '[profiles.default]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.setattr("kiwoom_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + monkeypatch.setenv("KIWOOM_DOMAIN", "prod") + + out = runner.invoke(cli, ["config", "show"]).output + assert "도메인: prod" in out, f"표 모드가 실제 접속 도메인을 숨긴다:\n{out}" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 320156a..338c28a 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -226,3 +226,49 @@ def test_dashboard_top_volume_uses_same_exchange_as_market_rank_volume( f"{rank_body['mang_stk_incls']!r} — 관리종목 포함 여부가 갈린다" ) assert dash_body == rank_body + + +def test_dashboard_names_failed_leg_with_code(runner, monkeypatch): + """dashboard의 3상태를 지킨다: 키 없음=미시도, null=실패. + partial_failures에는 실패한 것만 들어간다.""" + import json as _json + + from kiwoom_cli.client import KiwoomAPIError + from tests.fakes import FakeKiwoomClient + + fake = FakeKiwoomClient() + fake.token = "t" + fake.set_response("kt00004", {"return_code": 0, "entr": "1000"}) + + orig = fake.request + + def failing(api_id, body=None, **kw): + if api_id == "ka10030": + raise KiwoomAPIError(500, "boom") + return orig(api_id, body, **kw) + + monkeypatch.setattr(fake, "request", failing) + monkeypatch.setattr("kiwoom_cli.commands.dashboard.KiwoomClient", lambda *a, **k: fake) + + result = runner.invoke(cli, ["-f", "json", "dashboard"]) + assert result.exit_code == 0, result.output + doc = _json.loads(result.stdout) + assert doc["data"]["top_volume"] is None + assert doc["meta"]["partial_failures"] == {"top_volume": "UPSTREAM_ERROR"} + + +def test_dashboard_no_token_omits_key_and_has_no_partial_failures(runner, monkeypatch): + """토큰이 없어 시도조차 안 한 계좌는 실패가 아니다 — 키 자체가 없고 + partial_failures에도 들어가지 않는다.""" + import json as _json + + from tests.fakes import FakeKiwoomClient + + fake = FakeKiwoomClient() + fake.token = None + fake.set_response("ka10030", {"return_code": 0, "trde_qty_upper": [{"stk_cd": "005930"}]}) + monkeypatch.setattr("kiwoom_cli.commands.dashboard.KiwoomClient", lambda *a, **k: fake) + + doc = _json.loads(runner.invoke(cli, ["-f", "json", "dashboard"]).stdout) + assert "account" not in doc["data"] + assert "partial_failures" not in doc["meta"] diff --git a/tests/test_envelope.py b/tests/test_envelope.py index a6a4bd7..7106233 100644 --- a/tests/test_envelope.py +++ b/tests/test_envelope.py @@ -9,6 +9,7 @@ import json +import click import httpx import pytest from click.testing import CliRunner @@ -490,7 +491,9 @@ def test_auth_logout_json_envelope(runner, monkeypatch, configured_default): # revoke_token은 어느 토큰을 폐기했는지를 돌려준다 (envelope에 그대로 실림) monkeypatch.setattr( RealClient, "revoke_token", - lambda self: {"token_source": "keychain", "keychain_token_deleted": True}, + lambda self, force=False: { + "revoked": True, "token_source": "keychain", "keychain_token_deleted": True, + }, ) result = runner.invoke(cli, ["-f", "json", "auth", "logout"]) assert result.exit_code == 0, result.output @@ -573,3 +576,82 @@ def test_api_raw_json_mode_enveloped_unstripped(runner, monkeypatch): assert doc["ok"] is True assert doc["data"]["return_code"] == 0 assert doc["data"]["stk_nm"] == "삼성전자" + + +def test_meta_env_reports_domain_client_actually_used(runner, monkeypatch, tmp_path): + """meta.env는 클라이언트가 실제로 쓴 도메인의 '보고'여야 한다. + + 종전에는 KiwoomClient와 build_meta가 ctx.obj['profile']를 각자 읽고 각자 + 해석했다. 값이 같은 것은 우연이지 보장이 아니었다. AGENTS.md가 에이전트에게 + 주문 전 meta.env로 prod/mock을 확인하라고 지시하므로, 우연히 맞는 값은 + 잘못된 보장이다. + """ + cfg = tmp_path / "config.toml" + cfg.write_text( + '[general]\ndefault_profile = "sim"\n' + '[profiles.sim]\ndomain = "mock"\n' + '[profiles.live]\ndomain = "prod"\n' + ) + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + + result = runner.invoke(cli, ["-p", "live", "-f", "json", "auth", "status"]) + doc = json.loads(result.stdout) + assert doc["meta"]["profile"] == "live" + assert doc["meta"]["env"] == "prod" + + +def test_client_domain_and_meta_env_cannot_diverge(monkeypatch, tmp_path): + """meta.env와 KiwoomClient.domain은 같은 해석 결과를 공유해야 한다. + + auth status는 KiwoomClient를 만들지 않으므로 위 테스트만으로는 '보고한 + 값'과 '실제로 접속한 값'이 같다는 보장이 안 된다. 여기서 둘을 직접 + 맞대어 고정한다 — 이 불변식이 Task 2의 존재 이유다. + """ + from kiwoom_cli import envelope + from kiwoom_cli.client import KiwoomClient + + cfg = tmp_path / "config.toml" + cfg.write_text( + '[general]\ndefault_profile = "sim"\n' + '[profiles.sim]\ndomain = "mock"\n' + '[profiles.live]\ndomain = "prod"\n' + ) + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + monkeypatch.setattr("kiwoom_cli.auth.load_token", lambda profile=None: "tok") + + obj = {"profile": "live", "resolved_profile": "live", "domain_key": "prod"} + with click.Context(click.Command("x"), obj=obj): + with KiwoomClient() as c: + meta = envelope.build_meta() + assert c.domain == config.DOMAINS[meta["env"]], ( + f"보고한 env={meta['env']} 인데 실제 접속 도메인은 {c.domain}" + ) + assert c.profile == meta["profile"] + + +def test_partial_failures_appears_only_when_supplied(capsys): + """envelope.py에 build_doc 헬퍼가 없어 계획이 예고한 capsys 경로를 쓴다.""" + from kiwoom_cli import envelope as env + + env.emit(data={"kr": None}, partial_failures={"kr": "NOT_FOUND"}) + doc = json.loads(capsys.readouterr().out) + assert doc["meta"]["partial_failures"] == {"kr": "NOT_FOUND"} + assert doc["ok"] is True, "부분 실패는 ok를 뒤집지 않는다 (ok ≡ error is None 불변식)" + + env.emit(data={"kr": {}}) + plain = json.loads(capsys.readouterr().out) + assert "partial_failures" not in plain["meta"], \ + "성공 시에는 키 자체가 없어야 한다 (meta.fields_unmatched 선례)" + + +def test_partial_failures_empty_dict_is_omitted(capsys): + """빈 dict는 '실패한 레그 없음'이므로 키를 만들지 않는다.""" + from kiwoom_cli import envelope as env + + env.emit(data={"kr": {}}, partial_failures={}) + doc = json.loads(capsys.readouterr().out) + assert "partial_failures" not in doc["meta"] diff --git a/tests/test_order_safety.py b/tests/test_order_safety.py index 100a2c9..a73157a 100644 --- a/tests/test_order_safety.py +++ b/tests/test_order_safety.py @@ -1062,3 +1062,19 @@ def waiter(): assert entered.wait(timeout=2.0), "잠금 해제 후에도 진입하지 못했다" t.join(timeout=2.0) + + +def test_ledger_path_unchanged_by_resolution_refactor(monkeypatch, tmp_path): + """원장 경로가 움직이면 기록된 멱등키가 전부 안 보이게 되고, send_order가 + 이미 체결됐을 수 있는 주문을 재전송한다. 해석 방식을 바꾸는 작업에서 + 가장 위험한 부작용이라 값 자체를 리터럴로 고정한다.""" + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "default"\n[profiles.default]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + assert idempotency._ledger_file().name == "default-mock.jsonl" + + with click.Context(click.Command("x"), obj={"profile": "live", "resolved_profile": "live", + "domain_key": "prod"}): + assert idempotency._ledger_file().name == "live-prod.jsonl" diff --git a/tests/test_us.py b/tests/test_us.py index 71399d4..ba668f3 100644 --- a/tests/test_us.py +++ b/tests/test_us.py @@ -137,7 +137,7 @@ def tmp_cache(tmp_path, monkeypatch): """ monkeypatch.setattr("kiwoom_cli.config.CACHE_DIR", tmp_path) monkeypatch.setenv("KIWOOM_DOMAIN", "mock") - return tmp_path / "us_exchanges-mock.json" + return tmp_path / "us_exchanges2-mock.json" def _fake_with_10098(entries): @@ -823,3 +823,38 @@ def test_kr_chart_day_unchanged(runner, us_stock_fake): assert us_stock_fake.calls[0] == ("ka10081", { "stk_cd": "005930", "base_dt": "20260714", "upd_stkpc_tp": "0", }) + + +def test_exchange_cache_is_not_shared_across_profiles_with_different_domains( + monkeypatch, tmp_path +): + """기존 test_exchange_cache_is_not_shared_across_domains는 KIWOOM_DOMAIN + env로만 도메인을 갈랐다. config.py의 env 단락이 resolve_profile보다 먼저 + 반환하므로, 그 테스트는 detect.py가 profile을 넘기든 안 넘기든 똑같이 + 통과했다 — 미완 수정이 나간 정확한 이유다. 이 테스트는 -p 축을 몬다. + """ + import click + + from kiwoom_cli import config as _config + + cfg = tmp_path / "config.toml" + cfg.write_text( + '[general]\ndefault_profile = "sim"\n' + '[profiles.sim]\ndomain = "mock"\n' + '[profiles.live]\ndomain = "prod"\n' + ) + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.setattr(_config, "CACHE_DIR", tmp_path) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + + with click.Context(click.Command("x"), obj={"profile": "sim"}): + sim_file = detect._cache_file() + with click.Context(click.Command("x"), obj={"profile": "live"}): + live_file = detect._cache_file() + + assert sim_file != live_file, ( + f"-p로 프로필을 바꿔도 캐시 파일이 같다: {sim_file.name} — " + "모의에서 학습한 거래소가 실주문 stex_tp로 나간다" + ) + assert "mock" in sim_file.name and "prod" in live_file.name diff --git a/tests/test_us_account.py b/tests/test_us_account.py index 25c967a..a32fcd3 100644 --- a/tests/test_us_account.py +++ b/tests/test_us_account.py @@ -351,3 +351,56 @@ def failing(api_id, body=None, **kw): monkeypatch.setattr(acct_fake, "request", failing) result = runner.invoke(cli, ["account", "balance"]) assert result.exit_code == 2 + + +# ============================================================ +# meta.partial_failures — null이 '계좌 없음'인지 '조회 실패'인지 +# ============================================================ + + +def _fail_api(fake, monkeypatch, api_id, code=500, msg="fail"): + from kiwoom_cli.client import KiwoomAPIError + + orig = fake.request + + def failing(aid, body=None, **kw): + if aid == api_id: + raise KiwoomAPIError(code, msg) + return orig(aid, body, **kw) + + monkeypatch.setattr(fake, "request", failing) + + +def test_us_leg_failure_is_named_with_its_error_code(runner, acct_fake, monkeypatch): + """kr/us의 null이 '그 계좌가 없다'인지 '조회가 깨졌다'인지 구별되지 않던 + 자리. 이 프로젝트 저자 본인이 이것을 반대로 읽었다.""" + _fail_api(acct_fake, monkeypatch, "ust21070") + result = runner.invoke(cli, ["-f", "json", "account", "balance"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + assert doc["data"]["us"] is None + assert doc["meta"]["partial_failures"]["us"] == "UPSTREAM_ERROR" + assert "kr" not in doc["meta"]["partial_failures"] + + +def test_both_legs_ok_has_no_partial_failures_key(runner, acct_fake): + result = runner.invoke(cli, ["-f", "json", "account", "balance"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert "partial_failures" not in doc["meta"] + + +def test_data_key_set_is_unchanged_by_partial_failures(runner, acct_fake, monkeypatch): + """새 키는 meta에 산다 — data 모양을 고정한 기존 테스트들이 움직이면 안 된다.""" + _fail_api(acct_fake, monkeypatch, "ust21070") + doc = json.loads(runner.invoke(cli, ["-f", "json", "account", "balance"]).stdout) + assert set(doc["data"]) == {"kr", "us", "raw"} + + +def test_unified_structured_leg_failure_named(runner, acct_fake_full, monkeypatch): + """_unified_structured 경로(deposit 등 6개 명령)도 같이 알린다.""" + _fail_api(acct_fake_full, monkeypatch, "ust21160") + doc = json.loads(runner.invoke(cli, ["-f", "json", "account", "deposit"]).stdout) + assert doc["data"]["us"] is None + assert doc["meta"]["partial_failures"]["us"] == "UPSTREAM_ERROR" diff --git a/tests/test_ws_target.py b/tests/test_ws_target.py index eb95024..7479271 100644 --- a/tests/test_ws_target.py +++ b/tests/test_ws_target.py @@ -62,3 +62,59 @@ def test_build_meta_env_matches_domain_key(isolated_config, monkeypatch): ) meta = envelope.build_meta() assert meta["env"] == config.get_domain_key() == "mock" + + +class TestResolveProfileHonorsClickContext: + """resolve_profile의 docstring은 CLI --profile을 1순위로 문서화하지만 + 구현은 그것을 읽지 않았다. 호출자가 넘겨줄 때만 반영돼, 넘기지 않는 + 호출부(us/detect.py)가 조용히 다른 프로필로 해석했다.""" + + def test_ctx_profile_beats_default_profile(self, monkeypatch, tmp_path): + cfg = tmp_path / "config.toml" + cfg.write_text( + '[general]\ndefault_profile = "sim"\n' + '[profiles.sim]\ndomain = "mock"\n' + '[profiles.live]\ndomain = "prod"\n' + ) + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + monkeypatch.delenv("KIWOOM_DOMAIN", raising=False) + with click.Context(click.Command("x"), obj={"profile": "live"}): + assert config.resolve_profile() == "live" + assert config.get_domain_key() == "prod" + + def test_explicit_arg_still_wins_over_ctx(self, monkeypatch, tmp_path): + """명시 인자가 최우선. ctx가 그것을 덮으면 안 된다.""" + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "sim"\n[profiles.sim]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + with click.Context(click.Command("x"), obj={"profile": "live"}): + assert config.resolve_profile("explicit") == "explicit" + + def test_env_var_beats_ctx_absent_flag(self, monkeypatch, tmp_path): + """ctx.obj['profile']가 None이면(플래그 미지정) KIWOOM_PROFILE로 내려간다.""" + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "sim"\n[profiles.sim]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.setenv("KIWOOM_PROFILE", "fromenv") + with click.Context(click.Command("x"), obj={"profile": None}): + assert config.resolve_profile() == "fromenv" + + def test_no_context_still_works(self, monkeypatch, tmp_path): + """Click 컨텍스트 밖(테스트·라이브러리 사용)에서도 죽지 않는다.""" + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "sim"\n[profiles.sim]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + assert config.resolve_profile() == "sim" + + def test_empty_ctx_obj_tolerated(self, monkeypatch, tmp_path): + cfg = tmp_path / "config.toml" + cfg.write_text('[general]\ndefault_profile = "sim"\n[profiles.sim]\ndomain = "mock"\n') + monkeypatch.setattr("kiwoom_cli.config.CONFIG_FILE", cfg) + monkeypatch.delenv("KIWOOM_PROFILE", raising=False) + with click.Context(click.Command("x"), obj={}): + assert config.resolve_profile() == "sim" + with click.Context(click.Command("x"), obj=None): + assert config.resolve_profile() == "sim"