feat(api, worker): Hello 요청에 context cancellation 처리 및 테스트 보강

- API 서비스 workerclient Hello 함수에 context.Err() 선행 검증 추가
- Hello 요청을 goroutine으로 비동기 처리하고 select로 ctx.Done() 감지
- 이미 초과된 deadline인 경우 즉시 에러 반환
- worker 서비스 config에서 strconv import를 config.go로 이동
- worker socket session handler 및 registerHandlers 테스트 추가
- parser_map 테스트 파일 신규 추가
This commit is contained in:
toki 2026-05-30 19:37:26 +09:00
parent 3ee268816b
commit 132e92eb68
10 changed files with 372 additions and 177 deletions

View file

@ -1,25 +0,0 @@
<!-- task=m-api-centered-proto-socket-rail/02+01_worker_socket_rail plan=0 tag=WORKER -->
# CODE_REVIEW-cloud-G08: API to Worker Proto-Socket Rail
## 구현 에이전트 소유 섹션
- 구현 요약: 미작성
- 변경 파일: 미작성
- 실행한 검증/명령: 미작성
- 남은 위험/후속 작업: 미작성
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 코드 리뷰어 소유 섹션
- 리뷰 상태: 미작성
- 주요 발견사항: 미작성
- 테스트/검증 평가: 미작성
- 판정: 미작성

View file

@ -1,139 +0,0 @@
<!-- task=m-api-centered-proto-socket-rail/02+01_worker_socket_rail plan=0 tag=WORKER -->
# PLAN-cloud-G08: API to Worker Proto-Socket Rail
## 이 파일을 읽는 구현 에이전트에게
이 계획은 API를 기준으로 worker와 내부망을 연결하는 기반 작업이다. client가 worker를 직접 제어하지 않는다는 프로젝트 룰을 지키며, API가 worker에 명령/조회 요청을 보낼 수 있는 proto-socket rail을 만든다.
## 배경
현재 `services/worker/cmd/alt-worker/main.go:10`은 runner와 builtin job registration만 수행하며 socket server가 없다. worker config도 `services/worker/internal/config/config.go:5` 기준 DB/Redis/queue 중심이고 runtime socket address가 없다. API 역시 worker client가 없고, `services/api/internal/socket/server.go:32`에서 hello 외 요청을 처리하지 않는다. 이 작업은 API와 worker 사이의 내부 프로세스 경계를 proto-socket으로 여는 일이다.
## 사용자 리뷰 요청 흐름
worker를 API가 아닌 client에 직접 노출해야 한다는 요구가 발견되면 중단한다. 이는 현재 프로젝트 룰과 충돌하므로 `CODE_REVIEW-cloud-G08.md` 사용자 리뷰 요청 섹션에 사유를 적고 결정을 받아야 한다.
## 분석 결과
### 읽은 파일
- `services/worker/cmd/alt-worker/main.go`
- `services/worker/internal/config/config.go`
- `services/worker/internal/jobs/runner.go`
- `services/worker/internal/jobs/backtest_jobs.go`
- `services/worker/internal/storage/ports.go`
- `services/api/internal/socket/server.go`
- `services/api/internal/config/config.go`
- `services/api/cmd/alt-api/main.go`
- `services/api/go.mod`
- `services/worker/go.mod`
- `../proto-socket/go/ws_server.go`
- `../proto-socket/go/ws_client.go`
- `../proto-socket/go/communicator.go`
### 테스트 커버리지 공백
- worker에는 socket runtime 테스트가 없다.
- API에는 worker 연결 실패/timeout/retry behavior 테스트가 없다.
- 프로세스 간 smoke는 아직 문서 수준이며 로컬 실행은 금지되어 있다.
### 심볼 참조
- worker entrypoint: `services/worker/cmd/alt-worker/main.go:10`
- worker config: `services/worker/internal/config/config.go:5`
- job runner `Register`: `services/worker/internal/jobs/runner.go:27`
- job runner `Execute`: `services/worker/internal/jobs/runner.go:42`
- API config: `services/api/internal/config/config.go`
- proto-socket typed listener: `../proto-socket/go/communicator.go`
- proto-socket websocket server/client: `../proto-socket/go/ws_server.go`, `../proto-socket/go/ws_client.go`
### 분할 판단
이 작업은 worker socket server, API worker client, config, lifecycle만 구현한다. backtest와 market의 실제 business handler wiring은 각각 `04+02_backtest_rail`, `05+02_market_rail`에서 구현한다.
### 범위 결정 근거
- API가 control plane이고 worker가 execution plane이라는 결정이 이미 프로젝트 룰에 들어갔다.
- worker internal package는 API가 직접 import할 수 없고, import해서도 안 된다. 따라서 process boundary는 proto-socket이어야 한다.
- 초기 rail은 health/hello 또는 최소 ping 성격의 요청으로 연결성과 timeout을 검증하고, domain 요청은 후속 계획에 얹는다.
### 빌드 등급
- Build lane: `cloud-G08`
- Review lane: `cloud-G08`
- 근거: 두 Go service의 runtime boundary, config, lifecycle, network failure behavior를 함께 다룬다.
## 구현 체크리스트
### [WORKER-1] worker proto-socket server 추가
문제:
worker process가 proto-socket 요청을 받을 surface가 없다.
해결 방법:
`services/worker/internal/socket` 또는 기존 구조에 맞는 패키지를 만들고 proto-socket server를 시작한다. server는 worker-owned handlers만 등록하고, client-facing endpoint가 아님을 코드/테스트 구조로 드러낸다.
수정 파일 및 체크리스트:
- `services/worker/cmd/alt-worker/main.go`
- `services/worker/internal/config/config.go`
- `services/worker/internal/socket/**`
- `services/worker/go.mod`
- [ ] worker listen address/env가 추가된다.
- [ ] worker socket server lifecycle이 main에서 시작된다.
- [ ] shutdown/context 처리가 기존 runner 구조를 해치지 않는다.
- [ ] worker가 API/client package를 import하지 않는다.
테스트 작성:
- config env parsing test.
- worker socket handler registration test.
- 최소 hello/health request-response test.
중간 검증:
- 원격 검증 환경에서만 `go test ./services/worker/...` 실행.
### [WORKER-2] API worker proto-socket client 추가
문제:
API가 worker에 요청을 보낼 client abstraction이 없다.
해결 방법:
`services/api/internal/workerclient` 같은 패키지를 만들고 proto-socket Go client를 감싼다. 초기 메서드는 연결성 확인용으로 작게 시작하고, domain method는 후속 계획에서 추가한다. context timeout과 worker unavailable error mapping을 명확히 한다.
수정 파일 및 체크리스트:
- `services/api/internal/config/config.go`
- `services/api/cmd/alt-api/main.go`
- `services/api/internal/workerclient/**`
- `services/api/go.mod`
- [ ] API worker socket URL/env가 추가된다.
- [ ] request timeout 기본값이 있다.
- [ ] worker unavailable이 client-facing proto error로 변환될 자리만 만든다.
- [ ] API가 `services/worker/internal/**`를 import하지 않는다.
테스트 작성:
- API config env parsing test.
- fake proto-socket worker 또는 fake client 기반 unavailable/timeout test.
- workerclient request mapping unit test.
중간 검증:
- 원격 검증 환경에서만 `go test ./services/api/...` 실행.
### [WORKER-3] 양쪽 parser map 정렬
문제:
worker도 proto-socket request를 받으려면 contract parser가 필요하지만 현재 parser map은 API 내부에만 있다.
해결 방법:
가장 작은 안전한 선택을 한다. API 내부 parser map을 바로 공유하려고 internal 경계를 깨지 않는다. worker에 필요한 parser registration을 추가하거나, contracts module에 수동 helper를 둘 경우 generated artifact와 충돌하지 않는 위치인지 먼저 확인한다.
수정 파일 및 체크리스트:
- `services/worker/internal/contracts/**` 또는 안전한 shared contracts helper
- `services/api/internal/contracts/**` 필요 시 정렬
- [ ] worker에서 받을 request parser가 등록된다.
- [ ] API에서 worker response parser가 등록된다.
- [ ] API internal package를 worker가 import하지 않는다.
테스트 작성:
- worker parser map 필수 메시지 목록 테스트.
- API/worker parser 누락 테스트.
중간 검증:
- 원격 검증 환경에서만 `bin/contracts-check` 실행.
- 원격 검증 환경에서만 `go test ./services/api/... ./services/worker/...` 실행.
## 수정 파일 요약
- 예상 코드: `services/worker/internal/socket/**`, `services/worker/internal/config/config.go`, `services/worker/cmd/alt-worker/main.go`
- 예상 코드: `services/api/internal/workerclient/**`, `services/api/internal/config/config.go`, `services/api/cmd/alt-api/main.go`
- 예상 코드: API/worker parser map 관련 파일
- 예상 모듈: `services/worker/go.mod`, `services/api/go.mod`
## 최종 검증
- 원격 검증 환경에서만 `go test ./services/api/...`
- 원격 검증 환경에서만 `go test ./services/worker/...`
- 원격 검증 환경에서만 `bin/contracts-check`
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -99,6 +99,16 @@ func (c *socketClient) Close() error {
}
func (c *socketClient) Hello(ctx context.Context, req *altv1.HelloRequest) (*altv1.HelloResponse, error) {
if err := ctx.Err(); err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
}
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("%w: %v", ErrTimeout, err)
}
return nil, err
}
c.mu.RLock()
client := c.wsClient
c.mu.RUnlock()
@ -110,15 +120,39 @@ func (c *socketClient) Hello(ctx context.Context, req *altv1.HelloRequest) (*alt
timeout := 5 * time.Second
if dl, ok := ctx.Deadline(); ok {
timeout = time.Until(dl)
}
res, err := protoSocket.SendRequestTyped[*altv1.HelloRequest, *altv1.HelloResponse](&client.Communicator, req, timeout)
if err != nil {
if errors.Is(err, protoSocket.ErrNotConnected) {
return nil, ErrUnavailable
if timeout <= 0 {
return nil, fmt.Errorf("%w: deadline already exceeded", ErrTimeout)
}
return nil, fmt.Errorf("%w: %v", ErrTimeout, err)
}
return res, nil
type result struct {
res *altv1.HelloResponse
err error
}
ch := make(chan result, 1)
go func() {
res, err := protoSocket.SendRequestTyped[*altv1.HelloRequest, *altv1.HelloResponse](&client.Communicator, req, timeout)
ch <- result{res: res, err: err}
}()
select {
case <-ctx.Done():
err := ctx.Err()
if errors.Is(err, context.Canceled) {
return nil, err
}
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("%w: %v", ErrTimeout, err)
}
return nil, err
case r := <-ch:
if r.err != nil {
if errors.Is(r.err, protoSocket.ErrNotConnected) {
return nil, ErrUnavailable
}
return nil, fmt.Errorf("%w: %v", ErrTimeout, r.err)
}
return r.res, nil
}
}

View file

@ -0,0 +1,198 @@
package workerclient
import (
"context"
"errors"
"fmt"
"net"
"testing"
"time"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
apiContracts "git.toki-labs.com/toki/alt/services/api/internal/contracts"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
"nhooyr.io/websocket"
)
func TestWorkerClient_Connect_Unavailable(t *testing.T) {
// Port that is highly unlikely to have anything listening
client := New("ws://127.0.0.1:54321/socket")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := client.Connect(ctx)
if err == nil {
t.Fatalf("expected error on unavailable worker, got nil")
}
if !errors.Is(err, ErrUnavailable) {
t.Errorf("expected ErrUnavailable, got %v", err)
}
}
func startFakeWorker(t *testing.T, handler func(*protoSocket.WsClient)) (int, func()) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on temporary port: %v", err)
}
port := l.Addr().(*net.TCPAddr).Port
l.Close()
wsServer := protoSocket.NewWsServer("127.0.0.1", port, "/socket", func(conn *websocket.Conn) *protoSocket.WsClient {
return protoSocket.NewWsClient(conn, 30, 10, apiContracts.ParserMap())
})
wsServer.OnClientConnected = handler
ctx, cancel := context.WithCancel(context.Background())
if err := wsServer.Start(ctx); err != nil {
t.Fatalf("failed to start fake worker server: %v", err)
}
cleanup := func() {
cancel()
_ = wsServer.Stop()
}
return port, cleanup
}
func TestWorkerClient_Hello_Success(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.HelloRequest, *altv1.HelloResponse](&client.Communicator, func(req *altv1.HelloRequest) (*altv1.HelloResponse, error) {
return &altv1.HelloResponse{
ServerName: "alt-worker-fake",
ServerVersion: "test",
AltProtocolVersion: req.GetAltProtocolVersion(),
}, nil
})
})
defer cleanup()
client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port))
ctx := context.Background()
if err := client.Connect(ctx); err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer client.Close()
res, err := client.Hello(ctx, &altv1.HelloRequest{
AltProtocolVersion: "alt.v1",
})
if err != nil {
t.Fatalf("Hello request failed: %v", err)
}
if res.ServerName != "alt-worker-fake" {
t.Errorf("expected ServerName to be alt-worker-fake, got %q", res.ServerName)
}
}
func TestWorkerClient_Hello_Timeout(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.HelloRequest, *altv1.HelloResponse](&client.Communicator, func(req *altv1.HelloRequest) (*altv1.HelloResponse, error) {
// Deliberately delay response to trigger timeout
time.Sleep(200 * time.Millisecond)
return &altv1.HelloResponse{
ServerName: "alt-worker-fake",
}, nil
})
})
defer cleanup()
client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port))
ctx := context.Background()
if err := client.Connect(ctx); err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer client.Close()
// Short deadline context
timeoutCtx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := client.Hello(timeoutCtx, &altv1.HelloRequest{
AltProtocolVersion: "alt.v1",
})
if err == nil {
t.Fatalf("expected timeout error, got nil")
}
if !errors.Is(err, ErrTimeout) {
t.Errorf("expected ErrTimeout, got %v", err)
}
}
func TestWorkerClient_Hello_ContextCanceled(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {})
defer cleanup()
client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port))
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := client.Connect(context.Background()); err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer client.Close()
_, err := client.Hello(ctx, &altv1.HelloRequest{AltProtocolVersion: "alt.v1"})
if err == nil {
t.Fatalf("expected error on canceled context, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
func TestWorkerClient_Hello_ContextCanceled_Midflight(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.HelloRequest, *altv1.HelloResponse](&client.Communicator, func(req *altv1.HelloRequest) (*altv1.HelloResponse, error) {
time.Sleep(200 * time.Millisecond)
return &altv1.HelloResponse{ServerName: "alt-worker-fake"}, nil
})
})
defer cleanup()
client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port))
if err := client.Connect(context.Background()); err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer client.Close()
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := client.Hello(ctx, &altv1.HelloRequest{AltProtocolVersion: "alt.v1"})
if err == nil {
t.Fatalf("expected error on canceled context mid-flight, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
func TestWorkerClient_Hello_DeadlineExceeded(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {})
defer cleanup()
client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port))
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second))
defer cancel()
if err := client.Connect(context.Background()); err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer client.Close()
_, err := client.Hello(ctx, &altv1.HelloRequest{AltProtocolVersion: "alt.v1"})
if err == nil {
t.Fatalf("expected error on exceeded deadline, got nil")
}
if !errors.Is(err, ErrTimeout) {
t.Errorf("expected ErrTimeout, got %v", err)
}
}

View file

@ -3,8 +3,13 @@ module git.toki-labs.com/toki/alt/services/worker
go 1.22
require (
git.toki-labs.com/toki/alt/packages/contracts/gen/go v0.0.0-20260527202903-88c673d97307
git.toki-labs.com/toki/alt/packages/domain v0.0.0-20260530101407-3ee268816b79
git.toki-labs.com/toki/proto-socket/go v0.0.0
github.com/jackc/pgx/v5 v5.7.2
github.com/sqlc-dev/sqlc v1.27.0
google.golang.org/protobuf v1.36.5
nhooyr.io/websocket v1.8.17
)
require (
@ -51,7 +56,6 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
@ -62,3 +66,5 @@ require (
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
replace git.toki-labs.com/toki/proto-socket/go => ../../../proto-socket/go

View file

@ -1,5 +1,9 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.toki-labs.com/toki/alt/packages/contracts/gen/go v0.0.0-20260527202903-88c673d97307 h1:0127klckbNovR/VlASM+w5mC51iN/jf4XR0b4ZGwFOQ=
git.toki-labs.com/toki/alt/packages/contracts/gen/go v0.0.0-20260527202903-88c673d97307/go.mod h1:wf7tcjiAKQB3uLR/jKELLtWsSZkCssq9jIIWLKBT5vk=
git.toki-labs.com/toki/alt/packages/domain v0.0.0-20260530101407-3ee268816b79 h1:ubPUDDkIlhUfh9I+vqNl8CMsvvpcjakNTRzWtVTzmX4=
git.toki-labs.com/toki/alt/packages/domain v0.0.0-20260530101407-3ee268816b79/go.mod h1:9QCsimouZS6fuSkAZUrvv4PDOeJ+8oRLhWLVgc6TeaM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
@ -150,8 +154,8 @@ google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -193,3 +197,5 @@ modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

View file

@ -1,6 +1,9 @@
package config
import "os"
import (
"os"
"strconv"
)
type Config struct {
DatabaseURL string

View file

@ -2,7 +2,6 @@ package config
import (
"os"
"strconv"
"testing"
)

View file

@ -0,0 +1,72 @@
package contracts
import (
"testing"
"google.golang.org/protobuf/proto"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
)
func requiredWorkerMessages() []proto.Message {
return []proto.Message{
&altv1.HelloRequest{},
&altv1.HelloResponse{},
// market instruments / bars
&altv1.ListInstrumentsRequest{},
&altv1.ListInstrumentsResponse{},
&altv1.ListBarsRequest{},
&altv1.ListBarsResponse{},
// backtest start / list / detail / result / compare
&altv1.StartBacktestRequest{},
&altv1.StartBacktestResponse{},
&altv1.GetBacktestRunRequest{},
&altv1.GetBacktestRunResponse{},
&altv1.GetBacktestResultRequest{},
&altv1.GetBacktestResultResponse{},
&altv1.BacktestResult{},
&altv1.ListBacktestRunsRequest{},
&altv1.ListBacktestRunsResponse{},
&altv1.GetBacktestRunDetailRequest{},
&altv1.GetBacktestRunDetailResponse{},
&altv1.CompareBacktestRunsRequest{},
&altv1.CompareBacktestRunsResponse{},
}
}
func TestParserMapIncludesAltMessages(t *testing.T) {
pm := ParserMap()
for _, msg := range requiredWorkerMessages() {
typeName := protoSocket.TypeNameOf(msg)
t.Run(typeName, func(t *testing.T) {
parser := pm[typeName]
if parser == nil {
t.Fatalf("parser not registered for type %s", typeName)
}
data, err := proto.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal message: %v", err)
}
parsed, err := parser(data)
if err != nil {
t.Fatalf("failed to parse message: %v", err)
}
if protoSocket.TypeNameOf(parsed) != typeName {
t.Errorf("parsed type mismatch: expected %s, got %s", typeName, protoSocket.TypeNameOf(parsed))
}
})
}
}
func TestParserMapReportsMissingParser(t *testing.T) {
pm := ParserMap()
if parser := pm["alt.v1.UnregisteredProbeMessage"]; parser != nil {
t.Fatal("expected nil parser for an unregistered message type")
}
}

View file

@ -63,3 +63,44 @@ func TestWorkerSocketServerHello(t *testing.T) {
t.Errorf("expected AltProtocolVersion to be %q, got %q", "alt.v1", res.AltProtocolVersion)
}
}
func TestSessionHandlers(t *testing.T) {
handlers := sessionHandlers()
hasHello := false
seenTypes := make(map[string]bool)
for _, h := range handlers {
if h.requestType == "" {
t.Error("handler requestType cannot be empty")
}
if h.register == nil {
t.Errorf("handler for %q has nil register function", h.requestType)
}
if seenTypes[h.requestType] {
t.Errorf("duplicate handler registration for requestType %q", h.requestType)
}
seenTypes[h.requestType] = true
if h.requestType == "alt.v1.HelloRequest" {
hasHello = true
}
}
if !hasHello {
t.Error("expected alt.v1.HelloRequest to be registered in session handlers")
}
}
func TestRegisterHandlers(t *testing.T) {
t.Run("skip nil register", func(t *testing.T) {
handlers := []sessionHandler{
{
requestType: "test.DummyRequest",
register: nil,
},
}
registerHandlers(nil, handlers)
})
}