Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
353 lines
11 KiB
Go
353 lines
11 KiB
Go
package socket
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
|
|
"git.toki-labs.com/toki/alt/services/api/internal/config"
|
|
apiContracts "git.toki-labs.com/toki/alt/services/api/internal/contracts"
|
|
"git.toki-labs.com/toki/alt/services/api/internal/workerclient"
|
|
protoSocket "git.toki-labs.com/toki/proto-socket/go"
|
|
)
|
|
|
|
func TestServerRespondsToHelloRequest(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
cfg := config.Config{
|
|
Host: "127.0.0.1",
|
|
Port: freeTCPPort(t),
|
|
SocketPath: "/socket",
|
|
HeartbeatIntervalSec: 0,
|
|
HeartbeatWaitSec: 0,
|
|
}
|
|
// Test with unavailable worker
|
|
fakeWorker := &fakeWorkerClient{connectErr: workerclient.ErrUnavailable, isConnected: false}
|
|
server := NewServerWithWorker(cfg, fakeWorker)
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("failed to start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
client, err := protoSocket.DialWsWithHeartbeat(ctx, cfg.Host, cfg.Port, cfg.SocketPath, 0, 0, apiContracts.ParserMap())
|
|
if err != nil {
|
|
t.Fatalf("failed to dial server: %v", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
res, err := protoSocket.SendRequestTyped[*altv1.HelloRequest, *altv1.HelloResponse](
|
|
&client.Communicator,
|
|
&altv1.HelloRequest{
|
|
ClientName: "alt-test",
|
|
ClientVersion: "test",
|
|
AltProtocolVersion: "alt.v1",
|
|
},
|
|
2*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to send hello request: %v", err)
|
|
}
|
|
|
|
if res.GetServerName() != serverName {
|
|
t.Errorf("server name mismatch: expected %q, got %q", serverName, res.GetServerName())
|
|
}
|
|
if res.GetServerVersion() != serverVersion {
|
|
t.Errorf("server version mismatch: expected %q, got %q", serverVersion, res.GetServerVersion())
|
|
}
|
|
if res.GetAltProtocolVersion() != "alt.v1" {
|
|
t.Errorf("protocol version mismatch: expected %q, got %q", "alt.v1", res.GetAltProtocolVersion())
|
|
}
|
|
|
|
expectedCaps := map[string]bool{
|
|
"hello": true,
|
|
"request-response": true,
|
|
"market-read": true,
|
|
"market-import": true,
|
|
"backtest-read": true,
|
|
"backtest-start": true,
|
|
"worker-execution": true,
|
|
"paper-trading": true,
|
|
"worker-unavailable": true,
|
|
}
|
|
for _, cap := range res.GetCapabilities() {
|
|
if !expectedCaps[cap] {
|
|
t.Errorf("unexpected capability: %q", cap)
|
|
}
|
|
delete(expectedCaps, cap)
|
|
}
|
|
if len(expectedCaps) > 0 {
|
|
t.Errorf("missing expected capabilities: %v", expectedCaps)
|
|
}
|
|
}
|
|
|
|
func TestServerRespondsToHelloRequest_WorkerAvailable(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
cfg := config.Config{
|
|
Host: "127.0.0.1",
|
|
Port: freeTCPPort(t),
|
|
SocketPath: "/socket",
|
|
HeartbeatIntervalSec: 0,
|
|
HeartbeatWaitSec: 0,
|
|
}
|
|
// Test with available worker
|
|
fakeWorker := &fakeWorkerClient{isConnected: true}
|
|
server := NewServerWithWorker(cfg, fakeWorker)
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("failed to start server: %v", err)
|
|
}
|
|
defer server.Stop()
|
|
|
|
client, err := protoSocket.DialWsWithHeartbeat(ctx, cfg.Host, cfg.Port, cfg.SocketPath, 0, 0, apiContracts.ParserMap())
|
|
if err != nil {
|
|
t.Fatalf("failed to dial server: %v", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
res, err := protoSocket.SendRequestTyped[*altv1.HelloRequest, *altv1.HelloResponse](
|
|
&client.Communicator,
|
|
&altv1.HelloRequest{
|
|
ClientName: "alt-test",
|
|
ClientVersion: "test",
|
|
AltProtocolVersion: "alt.v1",
|
|
},
|
|
2*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to send hello request: %v", err)
|
|
}
|
|
|
|
expectedCaps := map[string]bool{
|
|
"hello": true,
|
|
"request-response": true,
|
|
"market-read": true,
|
|
"market-import": true,
|
|
"backtest-read": true,
|
|
"backtest-start": true,
|
|
"worker-execution": true,
|
|
"paper-trading": true,
|
|
"worker-available": true,
|
|
}
|
|
for _, cap := range res.GetCapabilities() {
|
|
if !expectedCaps[cap] {
|
|
t.Errorf("unexpected capability: %q", cap)
|
|
}
|
|
delete(expectedCaps, cap)
|
|
}
|
|
if len(expectedCaps) > 0 {
|
|
t.Errorf("missing expected capabilities: %v", expectedCaps)
|
|
}
|
|
}
|
|
|
|
func TestTwoServersIsolation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
cfg1 := config.Config{
|
|
Host: "127.0.0.1",
|
|
Port: freeTCPPort(t),
|
|
SocketPath: "/socket",
|
|
HeartbeatIntervalSec: 0,
|
|
HeartbeatWaitSec: 0,
|
|
}
|
|
fakeWorker1 := &fakeWorkerClient{isConnected: true}
|
|
server1 := NewServerWithWorker(cfg1, fakeWorker1)
|
|
if err := server1.Start(ctx); err != nil {
|
|
t.Fatalf("failed to start server 1: %v", err)
|
|
}
|
|
defer server1.Stop()
|
|
|
|
cfg2 := config.Config{
|
|
Host: "127.0.0.1",
|
|
Port: freeTCPPort(t),
|
|
SocketPath: "/socket",
|
|
HeartbeatIntervalSec: 0,
|
|
HeartbeatWaitSec: 0,
|
|
}
|
|
fakeWorker2 := &fakeWorkerClient{connectErr: workerclient.ErrUnavailable, isConnected: false}
|
|
server2 := NewServerWithWorker(cfg2, fakeWorker2)
|
|
if err := server2.Start(ctx); err != nil {
|
|
t.Fatalf("failed to start server 2: %v", err)
|
|
}
|
|
defer server2.Stop()
|
|
|
|
// Check server 1 capabilities
|
|
client1, err := protoSocket.DialWsWithHeartbeat(ctx, cfg1.Host, cfg1.Port, cfg1.SocketPath, 0, 0, apiContracts.ParserMap())
|
|
if err != nil {
|
|
t.Fatalf("failed to dial server 1: %v", err)
|
|
}
|
|
defer client1.Close()
|
|
|
|
res1, err := protoSocket.SendRequestTyped[*altv1.HelloRequest, *altv1.HelloResponse](
|
|
&client1.Communicator,
|
|
&altv1.HelloRequest{AltProtocolVersion: "alt.v1"},
|
|
2*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to send hello request to server 1: %v", err)
|
|
}
|
|
|
|
hasWorkerAvailable := false
|
|
for _, cap := range res1.GetCapabilities() {
|
|
if cap == "worker-available" {
|
|
hasWorkerAvailable = true
|
|
}
|
|
}
|
|
if !hasWorkerAvailable {
|
|
t.Error("expected server 1 to advertise worker-available")
|
|
}
|
|
|
|
// Check server 2 capabilities
|
|
client2, err := protoSocket.DialWsWithHeartbeat(ctx, cfg2.Host, cfg2.Port, cfg2.SocketPath, 0, 0, apiContracts.ParserMap())
|
|
if err != nil {
|
|
t.Fatalf("failed to dial server 2: %v", err)
|
|
}
|
|
defer client2.Close()
|
|
|
|
res2, err := protoSocket.SendRequestTyped[*altv1.HelloRequest, *altv1.HelloResponse](
|
|
&client2.Communicator,
|
|
&altv1.HelloRequest{AltProtocolVersion: "alt.v1"},
|
|
2*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to send hello request to server 2: %v", err)
|
|
}
|
|
|
|
hasWorkerUnavailable := false
|
|
for _, cap := range res2.GetCapabilities() {
|
|
if cap == "worker-unavailable" {
|
|
hasWorkerUnavailable = true
|
|
}
|
|
}
|
|
if !hasWorkerUnavailable {
|
|
t.Error("expected server 2 to advertise worker-unavailable")
|
|
}
|
|
}
|
|
|
|
func TestSessionHandlersHaveUniqueRequestTypes(t *testing.T) {
|
|
seen := make(map[string]int)
|
|
for _, handler := range sessionHandlers(nil) {
|
|
if handler.requestType == "" {
|
|
t.Errorf("session handler has empty request type")
|
|
continue
|
|
}
|
|
seen[handler.requestType]++
|
|
}
|
|
|
|
for requestType, count := range seen {
|
|
if count > 1 {
|
|
t.Errorf("request type %q registered %d times; duplicate handlers would panic the communicator", requestType, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionHandlersCoverRequiredRequests(t *testing.T) {
|
|
registered := make(map[string]bool)
|
|
for _, handler := range sessionHandlers(nil) {
|
|
registered[handler.requestType] = true
|
|
}
|
|
|
|
required := []string{
|
|
protoSocket.TypeNameOf(&altv1.HelloRequest{}),
|
|
}
|
|
for _, requestType := range required {
|
|
if !registered[requestType] {
|
|
t.Errorf("required handler for %q is not registered", requestType)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegisterHandlersSkipsNilRegistrar(t *testing.T) {
|
|
// registerHandlers must tolerate a malformed registry entry instead of
|
|
// panicking during connection setup. A nil registrar is skipped without
|
|
// dereferencing the client.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("registerHandlers panicked on nil registrar: %v", r)
|
|
}
|
|
}()
|
|
|
|
called := false
|
|
handlers := []sessionHandler{
|
|
{requestType: "alt.v1.NilRegistrarProbe", register: nil},
|
|
{requestType: "alt.v1.LiveRegistrarProbe", register: func(*protoSocket.WsClient) { called = true }},
|
|
}
|
|
registerHandlers(nil, handlers)
|
|
|
|
if !called {
|
|
t.Fatal("expected non-nil registrar to run after nil registrar was skipped")
|
|
}
|
|
}
|
|
|
|
func freeTCPPort(t *testing.T) int {
|
|
t.Helper()
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to reserve TCP port: %v", err)
|
|
}
|
|
defer listener.Close()
|
|
|
|
addr, ok := listener.Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
t.Fatalf("unexpected listener address type %T", listener.Addr())
|
|
}
|
|
return addr.Port
|
|
}
|
|
|
|
func TestCapabilitiesAndHandlersSync(t *testing.T) {
|
|
handlers := sessionHandlers(nil)
|
|
registered := make(map[string]bool)
|
|
for _, h := range handlers {
|
|
registered[h.requestType] = true
|
|
}
|
|
|
|
caps := capabilitiesForSession(nil)
|
|
capSet := make(map[string]bool)
|
|
for _, c := range caps {
|
|
capSet[c] = true
|
|
}
|
|
|
|
// 1. Check ListInstruments/ListBars -> market-read
|
|
hasMarketRequest := registered[protoSocket.TypeNameOf(&altv1.ListInstrumentsRequest{})] &&
|
|
registered[protoSocket.TypeNameOf(&altv1.ListBarsRequest{})]
|
|
if hasMarketRequest && !capSet["market-read"] {
|
|
t.Error("market-read capability missing although ListInstruments/ListBars handlers are registered")
|
|
}
|
|
|
|
// 1b. Check ImportDailyBars -> market-import
|
|
if registered[protoSocket.TypeNameOf(&altv1.ImportDailyBarsRequest{})] && !capSet["market-import"] {
|
|
t.Error("market-import capability missing although ImportDailyBars handler is registered")
|
|
}
|
|
|
|
// 2. Check backtest queries -> backtest-read
|
|
hasBacktestReadRequest := registered[protoSocket.TypeNameOf(&altv1.ListBacktestRunsRequest{})] &&
|
|
registered[protoSocket.TypeNameOf(&altv1.GetBacktestRunDetailRequest{})] &&
|
|
registered[protoSocket.TypeNameOf(&altv1.GetBacktestResultRequest{})] &&
|
|
registered[protoSocket.TypeNameOf(&altv1.CompareBacktestRunsRequest{})]
|
|
if hasBacktestReadRequest && !capSet["backtest-read"] {
|
|
t.Error("backtest-read capability missing although backtest query handlers are registered")
|
|
}
|
|
|
|
// 3. Check StartBacktest -> backtest-start, worker-execution
|
|
hasBacktestStartRequest := registered[protoSocket.TypeNameOf(&altv1.StartBacktestRequest{})]
|
|
if hasBacktestStartRequest {
|
|
if !capSet["backtest-start"] {
|
|
t.Error("backtest-start capability missing although StartBacktest handler is registered")
|
|
}
|
|
if !capSet["worker-execution"] {
|
|
t.Error("worker-execution capability missing although StartBacktest handler is registered")
|
|
}
|
|
}
|
|
|
|
// 4. Check paper trading start/state -> paper-trading
|
|
hasPaperRequest := registered[protoSocket.TypeNameOf(&altv1.StartPaperTradingRequest{})] &&
|
|
registered[protoSocket.TypeNameOf(&altv1.GetPaperTradingStateRequest{})]
|
|
if hasPaperRequest && !capSet["paper-trading"] {
|
|
t.Error("paper-trading capability missing although paper trading handlers are registered")
|
|
}
|
|
}
|