diff --git a/README.md b/README.md index 996655e..bc73a58 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Rust SDK core for Korea Investment & Securities Open API. `kis-sdk` is an early Rust client and local mock contract harness for KIS Open API integrations. The current typed SDK surface is intentionally narrow: it -focuses on OAuth token issuance and a small domestic stock slice while the -bundled mock server tracks the broader official endpoint inventory captured for -this project. +focuses on OAuth token issuance and a small domestic stock slice. A lower-level +inventory-backed execution API can address and call the broader bundled +official endpoint inventory by stable operation id while follow-on work adds +more ergonomic typed wrappers. ## Current Status @@ -27,6 +28,9 @@ this project. injection for tests and mock workflows. - Typed domestic stock methods for quotation price, balance inquiry, and cash order calls. +- Inventory-backed `execute_inventory` support for the bundled official + endpoint inventory, including required input/header validation and TR ID + selection rules from the captured metadata. - Local mock server generated from the bundled official endpoint inventory. - Explicit `RetryPolicy` and `FallbackPolicy` options. Retry is disabled by default. `RetryPolicy::conservative_reads()` retries retryable GET/read @@ -103,9 +107,31 @@ The typed SDK currently exposes: | `inquire_domestic_stock_balance` | `/uapi/domestic-stock/v1/trading/inquire-balance` | Domestic stock balance read. | | `place_domestic_stock_cash_order` | `/uapi/domestic-stock/v1/trading/order-cash` | Mock cash orders are supported; real cash orders are locally blocked by `KisError::LiveTradingDisabled`. | -The bundled contract and mock route inventory cover 338 official endpoints -across 22 collections. Endpoints outside the typed SDK surface are available as -contract/mock evidence, not as first-class typed Rust request methods yet. +The bundled inventory covers 338 official endpoints across 22 collections. +Endpoints outside the typed SDK surface do not yet have ergonomic typed Rust +request methods, but they can be addressed and called through the lower-level +inventory execution API with stable operation ids: + +```rust +use kis_sdk::endpoint::InventoryRequest; +use serde_json::json; + +let response = client + .execute_inventory::( + "domestic_stock_quotation.get_domestic_stock_quotations_inquire_price", + InventoryRequest::new().query(json!({ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": "005930" + })), + ) + .await?; +``` + +The inventory execution API follows the same safety boundary as the typed +methods: required query/body/non-standard header fields are validated before +network I/O, standard KIS headers are filled by the client, ambiguous TR IDs +require an explicit override, real-only endpoints are rejected in mock mode, and +real trading mutations are locally blocked. ## Credentials And Safety diff --git a/docs/usage.md b/docs/usage.md index d7d2792..6c70cb3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,9 +7,9 @@ their own secret-management path. The typed SDK surface currently covers OAuth token issuance, domestic stock price inquiry, domestic stock balance inquiry, and domestic stock cash-order -requests. The bundled mock server covers the captured official endpoint -inventory more broadly, but endpoints outside the typed surface are not yet -available as first-class Rust methods. +requests. The shared inventory-backed execution API can also call every endpoint +captured in `contracts/kis_official_endpoint_inventory.compact.json` by stable +operation id while follow-on work adds more ergonomic typed wrappers. ## Prerequisites @@ -97,6 +97,39 @@ async fn inquire_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisEr The current output type preserves provider fields as `serde_json::Value` so the SDK can expose the endpoint before broad typed response structs are finalized. +## Call An Inventory Endpoint + +Use `InventoryCatalog` to inspect generated operation ids and +`execute_inventory` to call a captured endpoint directly: + +```rust +use kis_sdk::endpoint::InventoryRequest; +use serde_json::json; + +async fn inventory_quote(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { + let response = client + .execute_inventory::( + "domestic_stock_quotation.get_domestic_stock_quotations_inquire_price", + InventoryRequest::new().query(json!({ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": "005930" + })), + ) + .await?; + + assert!(response.is_success()); + Ok(()) +} +``` + +The inventory layer validates required query, body, and non-standard header +fields before network I/O. Standard KIS headers such as `appkey`, `appsecret`, +`authorization`, `custtype`, `content-type`, and unambiguous `tr_id` values are +filled by the client. Endpoints with ambiguous TR IDs require +`InventoryRequest::tr_id_override(...)`. Real-only endpoints are rejected in +`Environment::Mock`, and real trading mutations remain blocked locally by +`KisError::LiveTradingDisabled`. + ## Inquire A Domestic Stock Balance ```rust diff --git a/src/client.rs b/src/client.rs index 81e6342..2c3935a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,7 +8,7 @@ use tokio::sync::Mutex; use crate::{ config::{Environment, KisConfig}, credentials::AppCredentials, - endpoint::{EndpointSpec, OperationKind, PreparedRequest}, + endpoint::{EndpointSpec, InventoryCatalog, InventoryRequest, OperationKind, PreparedRequest}, error::KisError, fallback::FallbackPolicy, retry::RetryPolicy, @@ -139,11 +139,61 @@ impl KisClient { T: DeserializeOwned, { let request = endpoint.prepare(self.config.environment, query, body, tr_id_override)?; + let fallback_request = + if self.should_fallback_to_mock(endpoint.operation_kind, request.method.as_str()) { + Some(endpoint.prepare(Environment::Mock, query, body, tr_id_override)?) + } else { + None + }; + self.execute_prepared( + endpoint.id, + endpoint.operation_kind, + request, + fallback_request, + ) + .await + } + + pub async fn execute_inventory( + &self, + operation_id: &str, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + let catalog = InventoryCatalog::bundled()?; + let (operation_kind, prepared) = + catalog.prepare(operation_id, self.config.environment, &request)?; + let fallback_request = + if self.should_fallback_to_mock(operation_kind, prepared.method.as_str()) { + Some( + catalog + .prepare(operation_id, Environment::Mock, &request)? + .1, + ) + } else { + None + }; + self.execute_prepared(operation_id, operation_kind, prepared, fallback_request) + .await + } + + async fn execute_prepared( + &self, + endpoint_id: &str, + operation_kind: OperationKind, + request: PreparedRequest, + fallback_request: Option, + ) -> Result, KisError> + where + T: DeserializeOwned, + { if self.config.environment == Environment::Real - && endpoint.operation_kind == OperationKind::TradingMutation + && operation_kind == OperationKind::TradingMutation { return Err(KisError::LiveTradingDisabled { - endpoint: endpoint.id.to_string(), + endpoint: endpoint_id.to_string(), }); } @@ -151,7 +201,7 @@ impl KisClient { loop { let result = self .send_prepared( - endpoint.operation_kind, + operation_kind, &request, &self.config.base_url, CredentialScope::Primary, @@ -165,7 +215,7 @@ impl KisClient { Err(error) if self.config.retry_policy.should_retry( request.method.as_str(), - endpoint.operation_kind, + operation_kind, &error, attempt, ) => @@ -175,16 +225,12 @@ impl KisClient { tokio::time::sleep(self.config.retry_policy.backoff()).await; } } - Err(error) - if error.retryable() - && self.should_fallback_to_mock(endpoint, request.method.as_str()) => - { - let fallback_request = - endpoint.prepare(Environment::Mock, query, body, tr_id_override)?; + Err(error) if error.retryable() && fallback_request.is_some() => { + let fallback_request = fallback_request.as_ref().expect("checked is_some"); let mut response = self .send_prepared( - endpoint.operation_kind, - &fallback_request, + operation_kind, + fallback_request, &self.config.fallback_base_url, CredentialScope::Fallback, ) @@ -203,13 +249,13 @@ impl KisClient { } } - fn should_fallback_to_mock(&self, endpoint: &EndpointSpec, method: &str) -> bool { + fn should_fallback_to_mock(&self, operation_kind: OperationKind, method: &str) -> bool { self.config.environment == Environment::Real - && endpoint.operation_kind == OperationKind::Read + && operation_kind == OperationKind::Read && self .config .fallback_policy - .allows_real_to_mock(method, endpoint.operation_kind) + .allows_real_to_mock(method, operation_kind) } async fn send_prepared( @@ -247,6 +293,10 @@ impl KisClient { builder = builder.header("tr_id", tr_id); } + for (name, value) in &request.headers { + builder = builder.header(name, value); + } + if let Some(body) = &request.body { builder = builder.json(body); } diff --git a/src/endpoint.rs b/src/endpoint.rs index cca03eb..319cb34 100644 --- a/src/endpoint.rs +++ b/src/endpoint.rs @@ -1,6 +1,8 @@ -use http::Method; +use std::collections::{HashMap, HashSet}; + +use http::{HeaderName, HeaderValue, Method}; use serde::Serialize; -use serde_json::Value; +use serde_json::{Map, Value}; use crate::{ config::Environment, @@ -32,6 +34,7 @@ pub(crate) struct PreparedRequest { pub tr_id: Option, pub body: Option, pub query: Vec<(String, String)>, + pub headers: Vec<(HeaderName, HeaderValue)>, } impl EndpointSpec { @@ -64,6 +67,7 @@ impl EndpointSpec { .transpose() .map_err(|error| KisError::Decode(error.to_string()))?, query: query.map(query_pairs).transpose()?.unwrap_or_default(), + headers: Vec::new(), }) } @@ -92,6 +96,261 @@ impl EndpointSpec { } } +#[derive(Clone, Debug)] +pub struct InventoryEndpointSpec { + pub operation_id: String, + pub contract_id: String, + pub collection_name: String, + pub method: Method, + pub path: String, + pub default_real_tr_id: Option, + pub default_mock_tr_id: Option, + pub operation_kind: OperationKind, + pub required_headers: Vec, + pub required_query: Vec, + pub required_body: Vec, + pub env_support: EnvironmentSupport, +} + +#[derive(Clone, Debug, Default)] +pub struct InventoryRequest { + query: Option, + body: Option, + headers: Vec<(String, String)>, + tr_id_override: Option, +} + +#[derive(Clone, Debug)] +pub struct InventoryCatalog { + endpoints: Vec, + by_operation_id: HashMap, +} + +impl InventoryRequest { + pub fn new() -> Self { + Self::default() + } + + pub fn query(mut self, query: impl Into) -> Self { + self.query = Some(query.into()); + self + } + + pub fn body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } + + pub fn header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } + + pub fn tr_id_override(mut self, tr_id: impl Into) -> Self { + self.tr_id_override = Some(tr_id.into()); + self + } +} + +impl InventoryCatalog { + pub fn bundled() -> Result { + let inventory = + ContractInventory::bundled().map_err(|error| KisError::Contract(error.to_string()))?; + Self::from_contract_inventory(&inventory) + } + + pub fn from_contract_inventory(inventory: &ContractInventory) -> Result { + let mut endpoints = Vec::with_capacity(inventory.endpoints.len()); + let mut by_operation_id = HashMap::with_capacity(inventory.endpoints.len()); + + for contract in &inventory.endpoints { + let operation_id = operation_id(contract); + let method = Method::from_bytes(contract.method.as_bytes()).map_err(|error| { + KisError::Contract(format!( + "invalid method {} for {}: {error}", + contract.method, contract.path + )) + })?; + let spec = InventoryEndpointSpec { + operation_id: operation_id.clone(), + contract_id: contract.id.clone(), + collection_name: contract.collection_name.clone(), + method, + path: contract.path.clone(), + default_real_tr_id: non_empty_tr_id(&contract.real_tr_id).map(str::to_string), + default_mock_tr_id: non_empty_tr_id(&contract.virtual_tr_id).map(str::to_string), + operation_kind: operation_kind(contract), + required_headers: contract.required_headers.clone(), + required_query: contract.required_query.clone(), + required_body: contract.required_body.clone(), + env_support: contract.env_support, + }; + + if by_operation_id + .insert(operation_id.clone(), endpoints.len()) + .is_some() + { + return Err(KisError::Contract(format!( + "duplicate inventory operation id {operation_id}" + ))); + } + endpoints.push(spec); + } + + Ok(Self { + endpoints, + by_operation_id, + }) + } + + pub fn endpoint_count(&self) -> usize { + self.endpoints.len() + } + + pub fn endpoints(&self) -> &[InventoryEndpointSpec] { + &self.endpoints + } + + pub fn endpoint(&self, operation_id: &str) -> Option<&InventoryEndpointSpec> { + self.by_operation_id + .get(operation_id) + .map(|index| &self.endpoints[*index]) + } + + pub(crate) fn prepare( + &self, + operation_id: &str, + environment: Environment, + request: &InventoryRequest, + ) -> Result<(OperationKind, PreparedRequest), KisError> { + let endpoint = self.endpoint(operation_id).ok_or_else(|| { + KisError::Contract(format!("missing inventory operation id {operation_id}")) + })?; + endpoint.prepare(environment, request) + } +} + +impl InventoryEndpointSpec { + pub(crate) fn prepare( + &self, + environment: Environment, + request: &InventoryRequest, + ) -> Result<(OperationKind, PreparedRequest), KisError> { + validate_inventory_environment(self, environment)?; + let query_required = self.required_query_fields(); + validate_required_fields("query", &query_required, request.query.as_ref())?; + if self.method != Method::GET { + validate_required_fields("body", &self.required_body, request.body.as_ref())?; + } + + let tr_id = self.select_tr_id(environment, request.tr_id_override.as_deref())?; + let headers = self.prepare_headers(request, tr_id.as_deref())?; + let query = request + .query + .as_ref() + .map(query_pairs) + .transpose()? + .unwrap_or_default(); + + Ok(( + self.operation_kind, + PreparedRequest { + method: self.method.clone(), + path: self.path.clone(), + tr_id, + body: request.body.clone(), + query, + headers, + }, + )) + } + + fn select_tr_id( + &self, + environment: Environment, + tr_id_override: Option<&str>, + ) -> Result, KisError> { + if let Some(tr_id) = tr_id_override { + return Ok(Some(tr_id.to_string())); + } + + let selected = match environment { + Environment::Real => self.default_real_tr_id.as_deref(), + Environment::Mock => self + .default_mock_tr_id + .as_deref() + .or(self.default_real_tr_id.as_deref()), + }; + + match selected { + Some(tr_id) if is_single_tr_id(tr_id) => Ok(Some(tr_id.to_string())), + Some(tr_id) if self.requires_header("tr_id") => Err(KisError::AmbiguousTrId { + endpoint: self.operation_id.clone(), + tr_id: tr_id.to_string(), + }), + Some(_) | None => Ok(None), + } + } + + fn prepare_headers( + &self, + request: &InventoryRequest, + tr_id: Option<&str>, + ) -> Result, KisError> { + let provided = request + .headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::>(); + + for header in &self.required_headers { + let normalized = header.to_ascii_lowercase(); + if is_auto_header(&normalized) { + if normalized == "tr_id" && tr_id.is_none() && !provided.contains("tr_id") { + return Err(KisError::Validation(format!( + "required header {header} is missing" + ))); + } + continue; + } + + if !provided.contains(&normalized) { + return Err(KisError::Validation(format!( + "required header {header} is missing" + ))); + } + } + + request + .headers + .iter() + .map(|(name, value)| { + let name = HeaderName::from_bytes(name.as_bytes()).map_err(|error| { + KisError::Validation(format!("invalid header {name}: {error}")) + })?; + let value = HeaderValue::from_str(value).map_err(|error| { + KisError::Validation(format!("invalid value for header {name}: {error}")) + })?; + Ok((name, value)) + }) + .collect() + } + + fn requires_header(&self, name: &str) -> bool { + self.required_headers + .iter() + .any(|header| header.eq_ignore_ascii_case(name)) + } + + fn required_query_fields(&self) -> Vec { + let mut fields = self.required_query.clone(); + if self.method == Method::GET { + fields.extend(self.required_body.iter().cloned()); + } + fields + } +} + fn validate_environment( contract: &ContractEndpoint, environment: Environment, @@ -107,6 +366,54 @@ fn validate_environment( Ok(()) } +fn validate_inventory_environment( + endpoint: &InventoryEndpointSpec, + environment: Environment, +) -> Result<(), KisError> { + if environment == Environment::Mock && endpoint.env_support == EnvironmentSupport::RealOnly { + return Err(KisError::UnsupportedEnvironment { + endpoint: endpoint.operation_id.clone(), + environment, + }); + } + + Ok(()) +} + +fn validate_required_fields( + location: &str, + required: &[String], + value: Option<&Value>, +) -> Result<(), KisError> { + if required.is_empty() { + return Ok(()); + } + + let object = match value { + Some(Value::Object(object)) => object, + Some(_) | None => { + return Err(KisError::Validation(format!( + "{location} must contain required fields: {}", + required.join(", ") + ))) + } + }; + + for field in required { + if !has_present_field(object, field) { + return Err(KisError::Validation(format!( + "required {location} field {field} is missing" + ))); + } + } + + Ok(()) +} + +fn has_present_field(object: &Map, field: &str) -> bool { + object.get(field).is_some_and(|value| !value.is_null()) +} + fn query_pairs(query: &T) -> Result, KisError> { let Value::Object(fields) = serde_json::to_value(query).map_err(|error| KisError::Decode(error.to_string()))? @@ -138,3 +445,89 @@ fn is_single_tr_id(value: &str) -> bool { .chars() .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit()) } + +fn non_empty_tr_id(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.contains("미지원") { + None + } else { + Some(trimmed) + } +} + +fn operation_kind(endpoint: &ContractEndpoint) -> OperationKind { + if endpoint.is_auth() { + OperationKind::Auth + } else { + match endpoint.kind.as_str() { + "quotation/info" | "websocket" => OperationKind::Read, + "trading/account" + if endpoint.method == "POST" && is_trading_mutation_path(&endpoint.path) => + { + OperationKind::TradingMutation + } + "trading/account" => OperationKind::Read, + _ if endpoint.method == "GET" => OperationKind::Read, + _ => OperationKind::TradingMutation, + } + } +} + +fn is_trading_mutation_path(path: &str) -> bool { + path.contains("/order") || path.ends_with("/buy") || path.ends_with("/sell") +} + +fn operation_id(endpoint: &ContractEndpoint) -> String { + format!( + "{}.{}_{}", + collection_slug(&endpoint.collection_name), + endpoint.method.to_ascii_lowercase(), + path_slug(&endpoint.path) + ) +} + +fn collection_slug(collection_name: &str) -> &'static str { + match collection_name { + "OAuth인증" => "oauth_authentication", + "[국내주식] 주문/계좌" => "domestic_stock_trading_account", + "[국내주식] 기본시세" => "domestic_stock_quotation", + "[국내주식] ELW 시세" => "domestic_stock_elw_quotation", + "[국내주식] 업종/기타" => "domestic_stock_sector_misc", + "[국내주식] 종목정보" => "domestic_stock_product_info", + "[국내주식] 시세분석" => "domestic_stock_market_analysis", + "[국내주식] 순위분석" => "domestic_stock_ranking_analysis", + "[국내주식] 실시간시세" => "domestic_stock_realtime_quotation", + "[국내선물옵션] 주문/계좌" => "domestic_futures_options_trading_account", + "[국내선물옵션] 기본시세" => "domestic_futures_options_quotation", + "[국내선물옵션] 실시간시세" => "domestic_futures_options_realtime_quotation", + "[해외주식] 주문/계좌" => "overseas_stock_trading_account", + "[해외주식] 기본시세" => "overseas_stock_quotation", + "[해외주식] 시세분석" => "overseas_stock_market_analysis", + "[해외주식] 실시간시세" => "overseas_stock_realtime_quotation", + "[해외선물옵션] 주문/계좌" => "overseas_futures_options_trading_account", + "[해외선물옵션] 기본시세" => "overseas_futures_options_quotation", + "[해외선물옵션]실시간시세" => "overseas_futures_options_realtime_quotation", + "[장내채권] 주문/계좌" => "bond_trading_account", + "[장내채권] 기본시세" => "bond_quotation", + "[장내채권] 실시간시세" => "bond_realtime_quotation", + _ => "unknown_collection", + } +} + +fn path_slug(path: &str) -> String { + path.trim_matches('/') + .split('/') + .filter(|segment| !matches!(*segment, "uapi" | "v1")) + .flat_map(|segment| segment.split('-')) + .filter(|segment| !segment.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>() + .join("_") +} + +fn is_auto_header(header: &str) -> bool { + matches!( + header, + "authorization" | "appkey" | "appsecret" | "content-type" | "custtype" | "tr_id" + ) +} diff --git a/tests/sdk_core.rs b/tests/sdk_core.rs index d15049c..8beb5eb 100644 --- a/tests/sdk_core.rs +++ b/tests/sdk_core.rs @@ -9,7 +9,7 @@ use kis_sdk::{ }, config::Environment, credentials::{Account, AppCredentials, SecretString}, - endpoint::OperationKind, + endpoint::{InventoryCatalog, InventoryRequest, OperationKind}, error::KisError, fallback::FallbackPolicy, mock::MockServer, @@ -55,6 +55,198 @@ async fn client_calls_mocked_domestic_stock_read_and_order_slice() { server.shutdown().await; } +#[test] +fn inventory_catalog_addresses_every_official_endpoint_with_unique_operation_ids() { + let catalog = InventoryCatalog::bundled().expect("inventory catalog builds"); + + assert_eq!(catalog.endpoint_count(), 338); + + for endpoint in catalog.endpoints() { + assert!( + catalog.endpoint(&endpoint.operation_id).is_some(), + "{} must be addressable by operation id", + endpoint.operation_id + ); + assert!( + !endpoint.operation_id.contains("unknown_collection"), + "{} must use a curated collection slug", + endpoint.operation_id + ); + } +} + +#[test] +fn inventory_operation_kind_uses_contract_kind_not_http_method_only() { + let catalog = InventoryCatalog::bundled().expect("inventory catalog builds"); + + let realtime = catalog + .endpoint("domestic_stock_realtime_quotation.post_tryitout_h0stcnt0") + .expect("realtime operation exists"); + assert_eq!(realtime.operation_kind, OperationKind::Read); + + let cash_order = catalog + .endpoint("domestic_stock_trading_account.post_domestic_stock_trading_order_cash") + .expect("cash order operation exists"); + assert_eq!(cash_order.operation_kind, OperationKind::TradingMutation); + + let balance = catalog + .endpoint("domestic_stock_trading_account.get_domestic_stock_trading_inquire_balance") + .expect("balance operation exists"); + assert_eq!(balance.operation_kind, OperationKind::Read); +} + +#[tokio::test] +async fn inventory_execute_calls_mocked_endpoint_by_operation_id() { + let server = MockServer::start().await.expect("mock server starts"); + let client = KisClient::builder(Environment::Mock) + .base_url(server.base_url()) + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let response = client + .execute_inventory::( + "domestic_stock_quotation.get_domestic_stock_quotations_inquire_price", + InventoryRequest::new().query(json!({ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": "005930" + })), + ) + .await + .expect("inventory-backed quote succeeds"); + + assert!(response.is_success()); + assert!(response.output.is_some()); + + server.shutdown().await; +} + +#[tokio::test] +async fn inventory_real_non_trading_post_is_not_blocked_by_live_trading_guard() { + let client = KisClient::builder(Environment::Real) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let error = client + .execute_inventory::( + "domestic_stock_realtime_quotation.post_tryitout_h0stcnt0", + InventoryRequest::new() + .header("approval_key", "test_approval_key") + .header("tr_type", "1") + .body(json!({ + "tr_id": "H0STCNT0", + "tr_key": "005930" + })), + ) + .await + .expect_err("unreachable local URL should fail at transport, not live trading guard"); + + assert!( + matches!(error, KisError::Transport(_)), + "expected transport error after passing live trading guard, got {error:?}" + ); +} + +#[tokio::test] +async fn inventory_execute_rejects_missing_required_query_before_network() { + let client = KisClient::builder(Environment::Mock) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let error = client + .execute_inventory::( + "domestic_stock_quotation.get_domestic_stock_quotations_inquire_price", + InventoryRequest::new().query(json!({ + "FID_COND_MRKT_DIV_CODE": "J" + })), + ) + .await + .expect_err("missing query field should fail locally"); + + assert!(matches!(error, KisError::Validation(_))); +} + +#[tokio::test] +async fn inventory_execute_requires_override_for_ambiguous_tr_id() { + let body = json!({ + "CANO": "12345678", + "ACNT_PRDT_CD": "01", + "PDNO": "005930", + "ORD_DVSN": "00", + "ORD_QTY": "1", + "ORD_UNPR": "70000" + }); + let client = KisClient::builder(Environment::Mock) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let error = client + .execute_inventory::( + "domestic_stock_trading_account.post_domestic_stock_trading_order_cash", + InventoryRequest::new().body(body), + ) + .await + .expect_err("ambiguous order TR ID should require override"); + + assert!(matches!(error, KisError::AmbiguousTrId { .. })); +} + +#[tokio::test] +async fn inventory_execute_rejects_missing_required_header_before_network() { + let client = KisClient::builder(Environment::Mock) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let error = client + .execute_inventory::( + "domestic_stock_realtime_quotation.post_tryitout_h0stcnt0", + InventoryRequest::new().body(json!({ + "tr_id": "H0STCNT0", + "tr_key": "005930" + })), + ) + .await + .expect_err("missing approval_key and tr_type should fail locally"); + + assert!(matches!(error, KisError::Validation(_))); +} + +#[tokio::test] +async fn inventory_execute_rejects_real_only_endpoint_in_mock_before_network() { + let client = KisClient::builder(Environment::Mock) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let error = client + .execute_inventory::( + "domestic_stock_quotation.get_domestic_stock_quotations_inquire_price_2", + InventoryRequest::new().query(json!({ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": "005930" + })), + ) + .await + .expect_err("real-only endpoint should not run against mock"); + + assert!(matches!(error, KisError::UnsupportedEnvironment { .. })); +} + #[tokio::test] async fn client_uses_static_token_and_redacts_secret_values() { let server = MockServer::start().await.expect("mock server starts");