alt/apps/cli/internal/operator/runner_live_test.go
toki adc0b12d82 feat(live-trading): 실거래 주문 lifecycle을 연결한다
실거래 주문 생성/조회/취소 흐름이 contracts, worker, API, CLI, Flutter parser까지 같은 메시지 경계로 이어져야 한다.\n\n브로커 호출 전 operator confirmation과 malformed order validation을 worker-owned runtime에서 막고, 리뷰 완료 산출물을 archive에 보존한다.
2026-06-07 17:29:45 +09:00

229 lines
6.5 KiB
Go

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 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")
}
}