실거래 주문 생성/조회/취소 흐름이 contracts, worker, API, CLI, Flutter parser까지 같은 메시지 경계로 이어져야 한다.\n\n브로커 호출 전 operator confirmation과 malformed order validation을 worker-owned runtime에서 막고, 리뷰 완료 산출물을 archive에 보존한다.
229 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|