package kis import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "git.toki-labs.com/toki/alt/packages/domain/market" workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest" "git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer" "git.toki-labs.com/toki/alt/services/worker/internal/storage" ) func TestAuthRequestsTokenAndParsesExpiry(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != tokenPath { t.Fatalf("unexpected path: got %q", r.URL.Path) } if r.Method != http.MethodPost { t.Fatalf("unexpected method: got %q", r.Method) } var payload map[string]string if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode auth payload: %v", err) } if payload["grant_type"] != "client_credentials" { t.Errorf("grant_type: got %q", payload["grant_type"]) } if payload["appkey"] != "app-key" || payload["appsecret"] != "app-secret" { t.Errorf("auth payload did not carry configured credentials") } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"token-value","access_token_token_expired":"2026-06-04 12:34:56"}`)) })) defer server.Close() client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"}) token, err := client.Auth(context.Background()) if err != nil { t.Fatalf("auth: %v", err) } if token.AccessToken != "token-value" { t.Errorf("access token: got %q", token.AccessToken) } if token.ExpiresAt.IsZero() { t.Error("expected parsed expiry, got zero time") } } func TestAuthUnavailableWhenCredentialsAreMissing(t *testing.T) { client := NewClient(Config{BaseURL: "https://example.invalid", AppKey: "", AppSecret: ""}) _, err := client.Auth(context.Background()) if err == nil { t.Fatal("expected unavailable error, got nil") } if !IsErrorKind(err, ErrorUnavailable) { t.Fatalf("error kind: got %v, want %s", err, ErrorUnavailable) } } func TestConfigFromEnvSelectsExplicitEnvironment(t *testing.T) { t.Run("paper", func(t *testing.T) { t.Setenv("KIS_ACTIVE_ENV", "paper") t.Setenv("KIS_PAPER_APP_KEY", "paper-key") t.Setenv("KIS_PAPER_APP_SECRET", "paper-secret") t.Setenv("KIS_PAPER_CANO", "paper-cano") t.Setenv("KIS_PAPER_ACNT_PRDT_CD", "01") cfg := ConfigFromEnv() if cfg.ConfigError != "" { t.Fatalf("config error: %s", cfg.ConfigError) } if cfg.Environment != EnvironmentPaper { t.Fatalf("environment: got %q, want %q", cfg.Environment, EnvironmentPaper) } if cfg.BaseURL != PaperBaseURL || cfg.AppKey != "paper-key" || cfg.AppSecret != "paper-secret" { t.Fatalf("paper config: got %+v", cfg) } }) t.Run("real", func(t *testing.T) { t.Setenv("KIS_ACTIVE_ENV", "real") t.Setenv("KIS_REAL_APP_KEY", "real-key") t.Setenv("KIS_REAL_APP_SECRET", "real-secret") t.Setenv("KIS_REAL_CANO", "real-cano") t.Setenv("KIS_REAL_ACNT_PRDT_CD", "01") cfg := ConfigFromEnv() if cfg.ConfigError != "" { t.Fatalf("config error: %s", cfg.ConfigError) } if cfg.Environment != EnvironmentReal { t.Fatalf("environment: got %q, want %q", cfg.Environment, EnvironmentReal) } if cfg.BaseURL != RealBaseURL || cfg.AppKey != "real-key" || cfg.AppSecret != "real-secret" { t.Fatalf("real config: got %+v", cfg) } }) } func TestConfigFromEnvRejectsMissingOrUnknownEnvironment(t *testing.T) { for _, activeEnv := range []string{"", "papre"} { t.Run("env="+activeEnv, func(t *testing.T) { t.Setenv("KIS_ACTIVE_ENV", activeEnv) client := NewClient(ConfigFromEnv()) _, err := client.Auth(context.Background()) if err == nil { t.Fatal("expected unavailable error, got nil") } if !IsErrorKind(err, ErrorUnavailable) { t.Fatalf("error kind: got %v, want %s", err, ErrorUnavailable) } if !strings.Contains(err.Error(), "KIS_ACTIVE_ENV") { t.Fatalf("error should mention KIS_ACTIVE_ENV: %v", err) } }) } } func TestInquireDailyItemChartPriceSendsHeadersAndDecodes(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != DailyItemChartPricePath { t.Fatalf("unexpected path: got %q", r.URL.Path) } if got := r.Header.Get("authorization"); got != "Bearer access-token" { t.Errorf("authorization header: got %q", got) } if got := r.Header.Get("tr_id"); got != DailyItemChartPriceTRID { t.Errorf("tr_id header: got %q", got) } if got := r.Header.Get("custtype"); got != "P" { t.Errorf("custtype header: got %q", got) } query := r.URL.Query() wantParams := map[string]string{ "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": "005930", "FID_INPUT_DATE_1": "20240527", "FID_INPUT_DATE_2": "20240528", "FID_PERIOD_DIV_CODE": "D", "FID_ORG_ADJ_PRC": "0", } for key, want := range wantParams { if got := query.Get(key); got != want { t.Errorf("query %s: got %q, want %q", key, got, want) } } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json")) })) defer server.Close() client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"}) resp, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{ Symbol: "005930", From: "20240527", To: "20240528", }) if err != nil { t.Fatalf("daily query: %v", err) } if resp.Output1.ShortCode != "005930" { t.Errorf("short code: got %q", resp.Output1.ShortCode) } if len(resp.Output2) != 2 { t.Errorf("bar rows: got %d, want 2", len(resp.Output2)) } } func TestInquireDailyItemChartPriceClassifiesQuotaAndMalformed(t *testing.T) { t.Run("quota", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"msg_cd":"RATE_LIMIT","msg1":"too many requests"}`)) })) defer server.Close() client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"}) _, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{ Symbol: "005930", From: "20240527", To: "20240528", }) if err == nil || !IsErrorKind(err, ErrorQuota) { t.Fatalf("error kind: got %v, want %s", err, ErrorQuota) } }) t.Run("malformed", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{`)) })) defer server.Close() client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"}) _, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{ Symbol: "005930", From: "20240527", To: "20240528", }) if err == nil || !IsErrorKind(err, ErrorMalformed) { t.Fatalf("error kind: got %v, want %s", err, ErrorMalformed) } }) } func TestInquireDailyItemChartPriceDoesNotExposeSecretsInError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"msg_cd":"AUTH","msg1":"invalid credential"}`)) })) defer server.Close() client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"}) _, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{ Symbol: "005930", From: "20240527", To: "20240528", }) if err == nil { t.Fatal("expected error, got nil") } text := err.Error() for _, secret := range []string{"app-key", "app-secret", "access-token"} { if strings.Contains(text, secret) { t.Fatalf("error exposed secret %q: %s", secret, text) } } } func TestLiveProviderFetchesAndNormalizesDailyBars(t *testing.T) { var authCalls, dailyCalls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case tokenPath: authCalls++ _, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`)) case DailyItemChartPricePath: dailyCalls++ if got := r.URL.Query().Get("FID_INPUT_ISCD"); got != "005930" { t.Errorf("symbol query: got %q", got) } _, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json")) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer server.Close() client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"}) provider := NewLiveProvider(client) seoul := time.FixedZone("Asia/Seoul", 9*60*60) items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{ Provider: market.ProviderKIS, Selector: market.UniverseSelector{ Kind: market.UniverseSelectorWatchlist, Venue: market.VenueKRX, Symbols: []string{"005930"}, }, From: time.Date(2024, 5, 27, 0, 0, 0, 0, seoul), To: time.Date(2024, 5, 28, 0, 0, 0, 0, seoul), }) if err != nil { t.Fatalf("fetch daily bars: %v", err) } if authCalls != 1 || dailyCalls != 1 { t.Fatalf("calls: auth=%d daily=%d, want 1 each", authCalls, dailyCalls) } if len(items) != 1 { t.Fatalf("items: got %d, want 1", len(items)) } if items[0].Instrument.ID != market.InstrumentID("KRX:005930") { t.Errorf("instrument id: got %q", items[0].Instrument.ID) } if got := items[0].Instrument.ProviderSymbols[string(market.ProviderKIS)]; got != "005930" { t.Errorf("provider symbol: got %q", got) } if len(items[0].Bars) != 2 { t.Fatalf("bars: got %d, want 2", len(items[0].Bars)) } if items[0].Bars[0].InstrumentID != items[0].Instrument.ID { t.Errorf("bar instrument id: got %q, want %q", items[0].Bars[0].InstrumentID, items[0].Instrument.ID) } } func TestLiveProviderRejectsMissingRange(t *testing.T) { provider := NewLiveProvider(NewClient(Config{BaseURL: "https://example.invalid", AppKey: "app-key", AppSecret: "app-secret"})) _, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{ Provider: market.ProviderKIS, Selector: market.UniverseSelector{Symbols: []string{"005930"}}, }) if err == nil || !IsErrorKind(err, ErrorMalformed) { t.Fatalf("error kind: got %v, want %s", err, ErrorMalformed) } } type importStore struct { instruments map[market.InstrumentID]market.Instrument bars map[importBarKey]market.Bar } type importBarKey struct { instrumentID market.InstrumentID timeframe market.Timeframe timestamp time.Time } func newImportStore() *importStore { return &importStore{ instruments: make(map[market.InstrumentID]market.Instrument), bars: make(map[importBarKey]market.Bar), } } func (s *importStore) UpsertInstrument(_ context.Context, inst market.Instrument) error { s.instruments[inst.ID] = inst return nil } func (s *importStore) GetInstrument(_ context.Context, id market.InstrumentID) (market.Instrument, error) { inst, ok := s.instruments[id] if !ok { return market.Instrument{}, storage.ErrInstrumentNotFound } return inst, nil } func (s *importStore) ListInstruments(_ context.Context) ([]market.Instrument, error) { out := make([]market.Instrument, 0, len(s.instruments)) for _, inst := range s.instruments { out = append(out, inst) } return out, nil } func (s *importStore) UpsertBar(_ context.Context, bar market.Bar) error { s.bars[importBarKey{bar.InstrumentID, bar.Timeframe, bar.Timestamp}] = bar return nil } func (s *importStore) GetBars(_ context.Context, id market.InstrumentID, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { var out []market.Bar for key, bar := range s.bars { if key.instrumentID != id || key.timeframe != timeframe { continue } if key.timestamp.Before(from) || key.timestamp.After(to) { continue } out = append(out, bar) } return out, nil } func TestLiveProviderImportFeedsBacktestBarSource(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case tokenPath: _, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`)) case DailyItemChartPricePath: _, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json")) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer server.Close() client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"}) provider := NewLiveProvider(client) store := newImportStore() imp := importer.New(provider, store) seoul := time.FixedZone("Asia/Seoul", 9*60*60) from := time.Date(2024, 5, 27, 0, 0, 0, 0, seoul) to := time.Date(2024, 5, 28, 0, 0, 0, 0, seoul) result, err := imp.ImportDailyBars(context.Background(), importer.DailyBarRequest{ Provider: market.ProviderKIS, Selector: market.UniverseSelector{ Kind: market.UniverseSelectorWatchlist, Venue: market.VenueKRX, Symbols: []string{"005930"}, }, From: from, To: to, }) if err != nil { t.Fatalf("import daily bars: %v", err) } if result.Instruments != 1 || result.Bars != 2 { t.Fatalf("import result: got %+v, want 1 instrument and 2 bars", result) } source := workerbacktest.NewStorageBarSource(store, store) bars, err := source.GetBars(context.Background(), market.MarketKR, market.TimeframeDaily, from, to) if err != nil { t.Fatalf("backtest bar source query: %v", err) } if len(bars) != 2 { t.Fatalf("query bars: got %d, want 2", len(bars)) } if bars[0].InstrumentID != market.InstrumentID("KRX:005930") { t.Fatalf("bar instrument id: got %q", bars[0].InstrumentID) } } func TestLiveProviderPaperSmoke(t *testing.T) { if os.Getenv("KIS_LIVE_SMOKE") != "1" { t.Skip("set KIS_LIVE_SMOKE=1 with SOPS-injected KIS paper env to run live smoke") } runLiveProviderSmoke(t, EnvironmentPaper) } func TestLiveProviderRealSmoke(t *testing.T) { if os.Getenv("KIS_REAL_LIVE_SMOKE") != "1" { t.Skip("set KIS_REAL_LIVE_SMOKE=1 with SOPS-injected KIS real env to run live smoke") } runLiveProviderSmoke(t, EnvironmentReal) } func runLiveProviderSmoke(t *testing.T, env Environment) { t.Helper() if os.Getenv("KIS_ACTIVE_ENV") != string(env) { t.Fatalf("KIS_ACTIVE_ENV must be %s for this live smoke", env) } provider := NewLiveProvider(NewClient(ConfigFromEnv())) seoul := time.FixedZone("Asia/Seoul", 9*60*60) items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{ Provider: market.ProviderKIS, Selector: market.UniverseSelector{ Kind: market.UniverseSelectorWatchlist, Venue: market.VenueKRX, Symbols: []string{"005930"}, }, From: time.Date(2024, 5, 27, 0, 0, 0, 0, seoul), To: time.Date(2024, 5, 28, 0, 0, 0, 0, seoul), }) if err != nil { t.Fatalf("%s live provider smoke: %v", env, err) } if len(items) != 1 { t.Fatalf("items: got %d, want 1", len(items)) } if items[0].Instrument.ID != market.InstrumentID("KRX:005930") { t.Fatalf("instrument id: got %q", items[0].Instrument.ID) } if len(items[0].Bars) == 0 { t.Fatal("expected at least one live bar") } }