package operator import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" ) // monthlyAggregateResp is the deterministic canned response the fake API returns // for the monthly aggregation evidence: one instrument, 60 source daily bars // aggregated into 3 monthly bars under a single rule id, matching SDD S04. func monthlyAggregateResp() *altv1.AggregateMonthlyBarsResponse { return &altv1.AggregateMonthlyBarsResponse{ Provider: "kis", InstrumentCount: 1, SourceDailyBarCount: 60, MonthlyBarCount: 3, Provenance: []*altv1.MonthlyProvenance{ { InstrumentId: "KRX:005930", SourceDailyBarCount: 60, MonthlyBarCount: 3, SourceStartYyyymmdd: "20240101", SourceEndYyyymmdd: "20240331", AggregationRuleId: "ohlcv-month-v1", SourceTimeframe: altv1.Timeframe_TIMEFRAME_DAILY, TargetTimeframe: altv1.Timeframe_TIMEFRAME_MONTHLY, }, }, } } // TestRunScenarioAggregateMonthlyBars proves the operator maps a monthly // aggregation scenario onto the AggregateMonthlyBars API call, forwards the // shared market vocabulary, and surfaces the monthly counts plus provenance // summary fields the SDD S04 evidence relies on. func TestRunScenarioAggregateMonthlyBars(t *testing.T) { api := &fakeAPI{aggregateMonthlyResp: monthlyAggregateResp()} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "monthly_bars_aggregation", Steps: []Step{{ ID: "aggregate_kr_monthly", Action: ActionAggregateMonthlyBars, Request: Request{ Provider: "kis", SelectorKind: "watchlist", Market: "kr", Venue: "krx", Name: "kr-core", Symbols: []string{"005930"}, FromYYYYMMDD: "20240101", ToYYYYMMDD: "20240331", }, Expect: Expect{Status: "ok", MinCount: intPtr(3)}, }}, } if err := sc.Validate(); err != nil { t.Fatalf("scenario validate: %v", err) } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } for _, want := range []string{ "scenario=monthly_bars_aggregation", "step=aggregate_kr_monthly", "action=aggregate_monthly_bars", "status=ok", "provider=kis", "instrument_count=1", "source_daily_bar_count=60", "monthly_bar_count=3", "provenance_count=1", "aggregation_rule_id=ohlcv-month-v1", } { if !strings.Contains(out, want) { t.Errorf("output %q missing %q", out, want) } } req := api.lastAggregateMonthlyReq() if req == nil { t.Fatal("server did not receive an aggregate_monthly_bars request") } if req.GetProvider() != "kis" || req.GetSelectorKind() != "watchlist" { t.Errorf("request provider/selector = %q/%q, want kis/watchlist", req.GetProvider(), req.GetSelectorKind()) } if req.GetMarket() != altv1.Market_MARKET_KR { t.Errorf("request market = %v, want MARKET_KR", req.GetMarket()) } if req.GetVenue() != altv1.Venue_VENUE_KRX { t.Errorf("request venue = %v, want VENUE_KRX", req.GetVenue()) } if !equalStrings(req.GetSymbols(), []string{"005930"}) { t.Errorf("request symbols = %v, want [005930]", req.GetSymbols()) } if req.GetFromYyyymmdd() != "20240101" || req.GetToYyyymmdd() != "20240331" { t.Errorf("request range = %q..%q, want 20240101..20240331", req.GetFromYyyymmdd(), req.GetToYyyymmdd()) } } // TestRunScenarioAggregateMonthlyBarsTypedError proves a typed aggregation error // is surfaced (not degraded to a transport error) and that the monthly count // fields are omitted from the error output so the evidence stays clean. func TestRunScenarioAggregateMonthlyBarsTypedError(t *testing.T) { url := startFakeAPIServer(t, &fakeAPI{ aggregateMonthlyResp: &altv1.AggregateMonthlyBarsResponse{ Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "no source daily bars"}, }, }) sc := &Scenario{ Name: "monthly_typed_error", Steps: []Step{{ ID: "aggregate1", Action: ActionAggregateMonthlyBars, Request: Request{ Provider: "kis", SelectorKind: "watchlist", Market: "kr", Symbols: []string{"005930"}, FromYYYYMMDD: "20240101", ToYYYYMMDD: "20240331", }, Expect: Expect{Status: "error", ErrorCode: "invalid_request"}, }}, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "error.code=invalid_request") { t.Errorf("output %q missing error.code=invalid_request", out) } if strings.Contains(out, "monthly_bar_count") { t.Errorf("output %q should not contain monthly_bar_count on an error step", out) } } // TestValidateAggregateMonthlyBars covers the dry-run validation contract: a // well-formed scenario parses, and each missing/invalid field is rejected before // any socket is opened. func TestValidateAggregateMonthlyBars(t *testing.T) { valid := ` name: valid_monthly timeout: 5s steps: - id: step1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: kr venue: krx symbols: ["005930"] from_yyyymmdd: "20240101" to_yyyymmdd: "20240331" expect: status: ok ` sc, err := ParseScenario([]byte(valid)) if err != nil { t.Fatalf("unexpected error parsing valid monthly scenario: %v", err) } if sc.Steps[0].Action != ActionAggregateMonthlyBars { t.Fatalf("action = %q, want %q", sc.Steps[0].Action, ActionAggregateMonthlyBars) } cases := []struct { name string yaml string want string }{ { name: "missing provider", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: selector_kind: watchlist market: kr symbols: ["005930"] from_yyyymmdd: "20240101" to_yyyymmdd: "20240331" `, want: "requires request.provider", }, { name: "empty symbols", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: kr symbols: [] from_yyyymmdd: "20240101" to_yyyymmdd: "20240331" `, want: "requires request.symbols", }, { name: "blank symbol", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: kr symbols: [" "] from_yyyymmdd: "20240101" to_yyyymmdd: "20240331" `, want: "cannot contain empty or blank values", }, { name: "invalid market", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: jp symbols: ["005930"] from_yyyymmdd: "20240101" to_yyyymmdd: "20240331" `, want: "unsupported market", }, { name: "invalid venue", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: kr venue: lse symbols: ["005930"] from_yyyymmdd: "20240101" to_yyyymmdd: "20240331" `, want: "unsupported venue", }, { name: "missing from date", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: kr symbols: ["005930"] to_yyyymmdd: "20240331" `, want: "requires request.from_yyyymmdd", }, { name: "bad date format", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: kr symbols: ["005930"] from_yyyymmdd: "2024-01-01" to_yyyymmdd: "20240331" `, want: "must be in YYYYMMDD format", }, { name: "from after to", yaml: ` name: m steps: - id: s1 action: aggregate_monthly_bars request: provider: kis selector_kind: watchlist market: kr symbols: ["005930"] from_yyyymmdd: "20240401" to_yyyymmdd: "20240331" `, want: "cannot be after", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := ParseScenario([]byte(tc.yaml)) if err == nil { t.Fatalf("expected validation error containing %q, got nil", tc.want) } if !strings.Contains(err.Error(), tc.want) { t.Errorf("error = %q, want it to contain %q", err.Error(), tc.want) } }) } } // TestMonthlyBarsFixture guards the deterministic monthly aggregation evidence: // the YAML passes dry-run validation, and running it against the canned response // reproduces the committed expected JSONL fixture byte-for-byte so the SDD S04 // evidence is stable across repeated runs. func TestMonthlyBarsFixture(t *testing.T) { yamlPath := filepath.Join("..", "..", "testdata", "operator", "monthly_bars_aggregation.yaml") sc, err := LoadScenario(yamlPath) if err != nil { t.Fatalf("monthly_bars_aggregation.yaml failed validation: %v", err) } api := &fakeAPI{aggregateMonthlyResp: monthlyAggregateResp()} url := startFakeAPIServer(t, api) var stdout bytes.Buffer code := RunScenario(context.Background(), sc, RunOptions{ APIURL: url, Timeout: 2 * time.Second, Format: OutputJSONL, }, &stdout) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, stdout.String()) } expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "monthly_bars_aggregation.jsonl") want, err := os.ReadFile(expectedPath) if err != nil { t.Fatalf("read expected fixture: %v", err) } if got := stdout.String(); got != string(want) { t.Errorf("monthly aggregation JSONL output drift:\n got=%q\nwant=%q", got, string(want)) } }