package operator import ( "bufio" "encoding/json" "os" "path/filepath" "strings" "testing" altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" ) func submittedLiveOrder(id string) *altv1.LiveOrder { return &altv1.LiveOrder{ Id: id, AccountId: "live-acct-1", InstrumentId: "KRX:005930", Side: "buy", Type: "kis_best_limit", Status: "submitted", BrokerId: "broker-lo-1", BrokerStatus: "SUBMITTED", } } func canceledLiveOrder(id string) *altv1.LiveOrder { o := submittedLiveOrder(id) o.Status = "canceled" o.BrokerStatus = "CANCELED" return o } func TestRunSubmitLiveOrderConfirmationRequiredTypedError(t *testing.T) { api := &fakeAPI{submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{ Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "operator confirmation is required"}, }} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_order_lifecycle", Steps: []Step{ { ID: "submit", Action: ActionSubmitLiveOrder, Request: Request{ AccountID: "live-acct-1", InstrumentID: "KRX:005930", Side: "buy", Quantity: "1", OrderType: "kis_best_limit", OperatorConfirmed: false, }, 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) } } func TestRunSubmitLiveOrderPreservesCustomOrderType(t *testing.T) { order := submittedLiveOrder("lo-live-acct-1-1") order.Type = "kis_best_limit" api := &fakeAPI{submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{Order: order}} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_order_lifecycle", Steps: []Step{ { ID: "submit", Action: ActionSubmitLiveOrder, Request: Request{ AccountID: "live-acct-1", InstrumentID: "KRX:005930", Side: "buy", Quantity: "1", OrderType: "kis_best_limit", OperatorConfirmed: true, OperatorID: "operator-1", }, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "live_order_id=lo-live-acct-1-1") { t.Errorf("output %q missing live_order_id=lo-live-acct-1-1", out) } got := api.lastSubmitLiveOrderReq() if got == nil { t.Fatal("server did not receive a submit live order request") } if got.GetIntent().GetType() != "kis_best_limit" { t.Errorf("server received order type = %q, want kis_best_limit", got.GetIntent().GetType()) } if !got.GetOperatorConfirmation().GetConfirmed() { t.Error("server received confirmed = false, want true") } } func TestRunSubmitLiveOrderCancelGetInterpolation(t *testing.T) { orderID := "lo-live-acct-1-1" api := &fakeAPI{ submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{Order: submittedLiveOrder(orderID)}, getLiveOrderResp: &altv1.GetLiveOrderResponse{Order: submittedLiveOrder(orderID)}, cancelLiveOrderResp: &altv1.CancelLiveOrderResponse{Order: canceledLiveOrder(orderID)}, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_order_lifecycle", Steps: []Step{ { ID: "submit", Action: ActionSubmitLiveOrder, Request: Request{ AccountID: "live-acct-1", InstrumentID: "KRX:005930", Side: "buy", Quantity: "1", OrderType: "kis_best_limit", OperatorConfirmed: true, OperatorID: "operator-1", }, Expect: Expect{Status: "ok", LiveOrderStatus: "submitted"}, }, { ID: "get", Action: ActionGetLiveOrder, Request: Request{ AccountID: "live-acct-1", LiveOrderID: "{{steps.submit.live_order.id}}", }, Expect: Expect{Status: "ok"}, }, { ID: "cancel", Action: ActionCancelLiveOrder, Request: Request{ AccountID: "live-acct-1", LiveOrderID: "{{steps.submit.live_order.id}}", }, Expect: Expect{Status: "ok", LiveOrderStatus: "canceled"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } for _, want := range []string{ "step=submit action=submit_live_order status=ok live_order_id=lo-live-acct-1-1 live_order_status=submitted", "step=get action=get_live_order status=ok live_order_id=lo-live-acct-1-1", "step=cancel action=cancel_live_order status=ok live_order_id=lo-live-acct-1-1 live_order_status=canceled", } { if !strings.Contains(out, want) { t.Errorf("output %q missing %q", out, want) } } // cancel step must receive the interpolated order id, not the raw placeholder got := api.lastCancelLiveOrderReq() if got == nil { t.Fatal("server did not receive a cancel live order request") } if got.GetLiveOrderId() != orderID { t.Errorf("cancel received live_order_id = %q, want interpolated %q", got.GetLiveOrderId(), orderID) } } func TestRunGetLiveRiskPolicy(t *testing.T) { api := &fakeAPI{ getLiveRiskPolicyResp: &altv1.GetLiveRiskPolicyResponse{ Policy: &altv1.LiveRiskPolicy{MaxDailyOrders: 5, MaxOpenOrders: 3, AllowShortSelling: false}, }, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_risk_kill_switch", Steps: []Step{ { ID: "get_policy", Action: ActionGetLiveRiskPolicy, Request: Request{AccountID: "live-acct-1"}, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "max_daily_orders=5") { t.Errorf("output %q missing max_daily_orders=5", out) } if !strings.Contains(out, "max_open_orders=3") { t.Errorf("output %q missing max_open_orders=3", out) } if !strings.Contains(out, "allow_short_selling=false") { t.Errorf("output %q missing allow_short_selling=false", out) } } func TestRunGetLiveKillSwitch(t *testing.T) { api := &fakeAPI{ getLiveKillSwitchResp: &altv1.GetLiveKillSwitchResponse{ State: &altv1.LiveKillSwitchState{Halted: true, Reason: "kill switch active by default"}, }, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_risk_kill_switch", Steps: []Step{ { ID: "get_ks", Action: ActionGetLiveKillSwitch, Request: Request{AccountID: "live-acct-1"}, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "kill_switch_halted=true") { t.Errorf("output %q missing kill_switch_halted=true", out) } } func TestRunSetLiveKillSwitchResume(t *testing.T) { api := &fakeAPI{ setLiveKillSwitchResp: &altv1.SetLiveKillSwitchResponse{ State: &altv1.LiveKillSwitchState{Halted: false, Reason: "operator cleared"}, }, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_risk_kill_switch", Steps: []Step{ { ID: "set_ks", Action: ActionSetLiveKillSwitch, Request: Request{ AccountID: "live-acct-1", Halted: false, KillSwitchReason: "operator cleared", }, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "kill_switch_halted=false") { t.Errorf("output %q missing kill_switch_halted=false", out) } got := api.lastSetLiveKillSwitchReq() if got == nil { t.Fatal("server did not receive a set_live_kill_switch request") } if got.GetHalted() != false { t.Errorf("server received halted = true, want false") } if got.GetReason() != "operator cleared" { t.Errorf("server received reason = %q, want %q", got.GetReason(), "operator cleared") } } func TestLiveRiskKillSwitchFixtureIsValid(t *testing.T) { yamlPath := filepath.Join("..", "..", "testdata", "operator", "live_risk_kill_switch.yaml") if _, err := LoadScenario(yamlPath); err != nil { t.Fatalf("live_risk_kill_switch.yaml failed validation: %v", err) } expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "live_risk_kill_switch.jsonl") f, err := os.Open(expectedPath) if err != nil { t.Fatalf("open expected fixture: %v", err) } defer f.Close() scanner := bufio.NewScanner(f) sawPolicy := false sawKillSwitch := false sawRiskDecision := false sawSummary := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("expected fixture line not valid JSON: %v", err) } if rec["action"] == "get_live_risk_policy" && rec["max_daily_orders"] != nil { sawPolicy = true } if rec["kill_switch_halted"] != nil { sawKillSwitch = true } if rec["risk_decision_allowed"] != nil { sawRiskDecision = true } if rec["type"] == "summary" { sawSummary = true } } if err := scanner.Err(); err != nil { t.Fatalf("scan expected fixture: %v", err) } if !sawPolicy { t.Error("expected fixture missing a get_live_risk_policy line with max_daily_orders") } if !sawKillSwitch { t.Error("expected fixture missing a kill_switch_halted field") } if !sawRiskDecision { t.Error("expected fixture missing a risk_decision_allowed field (risk-blocked submit evidence)") } if !sawSummary { t.Error("expected fixture missing a summary line") } } func TestRunSubmitLiveOrderRiskBlockedIncludesDecision(t *testing.T) { api := &fakeAPI{submitLiveOrderResp: &altv1.SubmitLiveOrderResponse{ Order: &altv1.LiveOrder{ Id: "lo-live-acct-1-1", Status: "rejected", BrokerId: "", }, Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "live order blocked by risk policy: kill switch is active"}, RiskDecision: &altv1.LiveRiskDecision{Allowed: false, Reason: "kill switch is active"}, }} url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_risk_kill_switch", Steps: []Step{ { ID: "submit_risk_blocked", Action: ActionSubmitLiveOrder, Request: Request{ AccountID: "live-acct-1", InstrumentID: "KRX:005930", Side: "buy", Quantity: "1", OrderType: "market", OperatorConfirmed: true, OperatorID: "operator-1", }, 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, "live_order_id=lo-live-acct-1-1") { t.Errorf("output %q missing live_order_id=lo-live-acct-1-1", out) } if !strings.Contains(out, "live_order_status=rejected") { t.Errorf("output %q missing live_order_status=rejected", out) } if !strings.Contains(out, "risk_decision_allowed=false") { t.Errorf("output %q missing risk_decision_allowed=false", out) } if !strings.Contains(out, "kill switch is active") { t.Errorf("output %q missing kill switch reason", out) } } func TestLiveOrderLifecycleFixtureIsValid(t *testing.T) { yamlPath := filepath.Join("..", "..", "testdata", "operator", "live_order_lifecycle.yaml") if _, err := LoadScenario(yamlPath); err != nil { t.Fatalf("live_order_lifecycle.yaml failed validation: %v", err) } expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "live_order_lifecycle.jsonl") f, err := os.Open(expectedPath) if err != nil { t.Fatalf("open expected fixture: %v", err) } defer f.Close() scanner := bufio.NewScanner(f) sawLiveOrderID := false sawCanceled := false sawSummary := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("expected fixture line not valid JSON: %v", err) } if rec["live_order_id"] != nil { sawLiveOrderID = true } if rec["live_order_status"] == "canceled" { sawCanceled = true } if rec["type"] == "summary" { sawSummary = true } } if err := scanner.Err(); err != nil { t.Fatalf("scan expected fixture: %v", err) } if !sawLiveOrderID { t.Error("expected fixture missing a live_order_id line") } if !sawCanceled { t.Error("expected fixture missing a live_order_status=canceled transition") } if !sawSummary { t.Error("expected fixture missing a summary line") } } func TestRunSyncLiveAccount(t *testing.T) { snap := &altv1.LiveAccountSnapshot{ AccountId: "op-alias-001", Broker: "kis", SyncedAtUnixMs: 1749340800000, Cash: []*altv1.LiveCashBalance{ {Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "5000000"}}, }, } api := &fakeAPI{ syncLiveAccountResp: &altv1.SyncLiveAccountResponse{Snapshot: snap}, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_account_sync_test", Steps: []Step{ { ID: "sync", Action: ActionSyncLiveAccount, Request: Request{AccountID: "op-alias-001"}, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != 0 { t.Fatalf("expected exit 0, got %d\noutput:\n%s", code, out) } req := api.lastSyncLiveAccountReq() if req == nil { t.Fatal("expected server to receive SyncLiveAccountRequest") } if req.GetAccountId() != "op-alias-001" { t.Errorf("account_id mismatch: %q", req.GetAccountId()) } } func TestRunGetLiveAccountSnapshot(t *testing.T) { snap := &altv1.LiveAccountSnapshot{ AccountId: "op-alias-001", Broker: "kis", SyncedAtUnixMs: 1749340800000, } api := &fakeAPI{ getLiveAccountSnapResp: &altv1.GetLiveAccountSnapshotResponse{Snapshot: snap}, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_account_get_test", Steps: []Step{ { ID: "get", Action: ActionGetLiveAccountSnapshot, Request: Request{AccountID: "op-alias-001"}, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != 0 { t.Fatalf("expected exit 0, got %d\noutput:\n%s", code, out) } req := api.lastGetLiveAccountSnapReq() if req == nil { t.Fatal("expected server to receive GetLiveAccountSnapshotRequest") } if req.GetAccountId() != "op-alias-001" { t.Errorf("account_id mismatch: %q", req.GetAccountId()) } } func TestRunSyncLiveAccountValidationRejectsMissingAccountID(t *testing.T) { sc := Scenario{ Name: "live_account_missing_id", Steps: []Step{ { ID: "sync", Action: ActionSyncLiveAccount, }, }, } err := sc.Validate() if err == nil { t.Fatal("expected validation error for missing account_id") } } func TestRunGetLiveAccountSnapshotValidationRejectsMissingAccountID(t *testing.T) { sc := Scenario{ Name: "live_account_snap_missing_id", Steps: []Step{ { ID: "get", Action: ActionGetLiveAccountSnapshot, }, }, } err := sc.Validate() if err == nil { t.Fatal("expected validation error for missing account_id") } } func TestRunListLiveAuditEvents(t *testing.T) { api := &fakeAPI{ listLiveAuditEventsResp: &altv1.ListLiveAuditEventsResponse{ Events: []*altv1.LiveAuditEvent{ {EventId: "aud-1", Type: "submit_confirmed", AccountId: "op-audit-test-001", OrderId: "lo-1"}, }, }, } url := startFakeAPIServer(t, api) sc := &Scenario{ Name: "live_audit_query", Steps: []Step{ { ID: "list_audit", Action: ActionListLiveAuditEvents, Request: Request{AccountID: "op-audit-test-001"}, Expect: Expect{Status: "ok"}, }, }, } out, code := runScenario(t, sc, url) if code != codeOK { t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) } if !strings.Contains(out, "audit_event_count=1") { t.Errorf("output %q missing audit_event_count=1", out) } req := api.lastListLiveAuditEventsReq() if req == nil { t.Fatal("server did not receive ListLiveAuditEventsRequest") } if req.GetAccountId() != "op-audit-test-001" { t.Errorf("account_id mismatch: %q", req.GetAccountId()) } } func TestLiveAuditQueryFixtureIsValid(t *testing.T) { yamlPath := filepath.Join("..", "..", "testdata", "operator", "live_audit_query.yaml") if _, err := LoadScenario(yamlPath); err != nil { t.Fatalf("live_audit_query.yaml failed validation: %v", err) } expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "live_audit_query.jsonl") f, err := os.Open(expectedPath) if err != nil { t.Fatalf("open expected fixture: %v", err) } defer f.Close() scanner := bufio.NewScanner(f) sawAuditCount := false sawSummary := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("expected fixture line not valid JSON: %v", err) } if rec["action"] == "list_live_audit_events" && rec["audit_event_count"] != nil { sawAuditCount = true } if rec["type"] == "summary" { sawSummary = true } } if err := scanner.Err(); err != nil { t.Fatalf("scan expected fixture: %v", err) } if !sawAuditCount { t.Error("expected fixture missing a list_live_audit_events line with audit_event_count") } if !sawSummary { t.Error("expected fixture missing a summary line") } } func TestRunListLiveAuditEventsValidationRejectsMissingAccountID(t *testing.T) { sc := Scenario{ Name: "live_audit_missing_id", Steps: []Step{ { ID: "list", Action: ActionListLiveAuditEvents, }, }, } err := sc.Validate() if err == nil { t.Fatal("expected validation error for missing account_id") } } func TestEvaluateLiveAccountTypedError(t *testing.T) { ev := StepEvent{Action: string(ActionSyncLiveAccount)} step := Step{Expect: Expect{Status: "ok"}} errInfo := &altv1.ErrorInfo{Code: "not_found", Message: "snapshot not found"} ev2, code := evaluateLiveAccount(ev, step, errInfo, nil) if code == codeOK { t.Error("expected non-OK code for typed error") } if ev2.ErrorCode != "not_found" { t.Errorf("error_code mismatch: %q", ev2.ErrorCode) } } func TestEvaluateLiveAccountExpectedError(t *testing.T) { ev := StepEvent{Action: string(ActionGetLiveAccountSnapshot)} step := Step{Expect: Expect{Status: "error", ErrorCode: "not_found"}} errInfo := &altv1.ErrorInfo{Code: "not_found", Message: "snapshot not found"} ev2, code := evaluateLiveAccount(ev, step, errInfo, nil) if code != codeOK { t.Errorf("expected codeOK for matched typed error, got %d", code) } if ev2.Status != statusOK { t.Errorf("expected status ok, got %q", ev2.Status) } }