connector impl

This commit is contained in:
Ilia Denisov
2026-03-14 21:11:51 +02:00
committed by GitHub
parent c2d2cebe3e
commit ac3ed31a23
9 changed files with 863 additions and 6 deletions
+597
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"galaxy/connector"
"galaxy/model/report"
"io"
"net"
stdhttp "net/http"
@@ -32,6 +33,40 @@ type checkVersionCase struct {
wantPath string
}
// downloadVersionCase describes one DownloadVersion behavior scenario.
type downloadVersionCase struct {
name string
setup func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T))
want []byte
wantErr bool
wantPath string
}
// fetchReportCase describes one FetchReport behavior scenario.
type fetchReportCase struct {
name string
setup func(t *testing.T) (*httpConnector, <-chan requestDetails)
turn uint
want report.Report
wantErr bool
wantPath string
wantPlayer string
wantTurn string
wantRequest bool
}
// fetchReportResult captures one asynchronous FetchReport callback result.
type fetchReportResult struct {
report report.Report
err error
}
// requestDetails captures one received request path and query parameters.
type requestDetails struct {
path string
query url.Values
}
// TestCheckConnection verifies backend reachability probe behavior.
func TestCheckConnection(t *testing.T) {
tests := []checkConnectionCase{
@@ -225,6 +260,346 @@ func TestCheckVersion(t *testing.T) {
}
}
// TestFetchReport verifies asynchronous report retrieval behavior.
func TestFetchReport(t *testing.T) {
tests := []fetchReportCase{
{
name: "status 200 with valid body returns report",
setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
return newFetchReportServerConnector(
t,
context.Background(),
stdhttp.StatusOK,
`{"version":2,"turn":7,"mapWidth":120,"mapHeight":80,"race":"Race_01","votes":1.5}`,
"",
)
},
turn: 7,
want: report.Report{
Version: 2,
Turn: 7,
Width: 120,
Height: 80,
Race: "Race_01",
Votes: report.Float(1.5),
},
wantPath: "/api/v1/report",
wantPlayer: fetchReportPlayer,
wantTurn: "7",
wantRequest: true,
},
{
name: "status 200 with invalid json returns error",
setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
return newFetchReportServerConnector(
t,
context.Background(),
stdhttp.StatusOK,
`{"turn":`,
"",
)
},
turn: 8,
wantErr: true,
wantPath: "/api/v1/report",
wantPlayer: fetchReportPlayer,
wantTurn: "8",
wantRequest: true,
},
{
name: "non-200 status returns error",
setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
return newFetchReportServerConnector(
t,
context.Background(),
stdhttp.StatusBadGateway,
`{}`,
"",
)
},
turn: 9,
wantErr: true,
wantPath: "/api/v1/report",
wantPlayer: fetchReportPlayer,
wantTurn: "9",
wantRequest: true,
},
{
name: "canceled context returns error",
setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return newFetchReportServerConnector(
t,
ctx,
stdhttp.StatusOK,
`{"turn":1}`,
"",
)
},
turn: 10,
wantErr: true,
wantRequest: false,
},
{
name: "backend path prefix is preserved",
setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
return newFetchReportServerConnector(
t,
context.Background(),
stdhttp.StatusOK,
`{"turn":11,"mapWidth":20,"mapHeight":30}`,
"/base",
)
},
turn: 11,
want: report.Report{
Turn: 11,
Width: 20,
Height: 30,
},
wantPath: "/base/api/v1/report",
wantPlayer: fetchReportPlayer,
wantTurn: "11",
wantRequest: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conn, requestCh := tt.setup(t)
resultCh := make(chan fetchReportResult, 1)
conn.FetchReport("", tt.turn, func(rep report.Report, err error) {
resultCh <- fetchReportResult{report: rep, err: err}
})
select {
case result := <-resultCh:
if tt.wantErr {
if result.err == nil {
t.Fatal("FetchReport() error = nil, want non-nil")
}
} else {
if result.err != nil {
t.Fatalf("FetchReport() error = %v, want nil", result.err)
}
if !reflect.DeepEqual(result.report, tt.want) {
t.Fatalf("FetchReport() report = %#v, want %#v", result.report, tt.want)
}
}
case <-time.After(time.Second):
t.Fatal("FetchReport() callback was not called")
}
if !tt.wantRequest {
select {
case req := <-requestCh:
t.Fatalf("unexpected request = %#v", req)
case <-time.After(100 * time.Millisecond):
}
return
}
select {
case req := <-requestCh:
if req.path != tt.wantPath {
t.Fatalf("request path = %q, want %q", req.path, tt.wantPath)
}
if got := req.query.Get("player"); got != tt.wantPlayer {
t.Fatalf("request player = %q, want %q", got, tt.wantPlayer)
}
if got := req.query.Get("turn"); got != tt.wantTurn {
t.Fatalf("request turn = %q, want %q", got, tt.wantTurn)
}
case <-time.After(time.Second):
t.Fatal("expected request, got none")
}
})
}
}
// TestFetchReportAsync verifies FetchReport returns immediately and calls callback once after response is ready.
func TestFetchReportAsync(t *testing.T) {
requestCh := make(chan requestDetails, 1)
releaseResponse := make(chan struct{})
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
requestCh <- requestDetails{path: r.URL.Path, query: r.URL.Query()}
<-releaseResponse
w.WriteHeader(stdhttp.StatusOK)
_, _ = w.Write([]byte(`{"turn":12,"mapWidth":1,"mapHeight":1}`))
}))
t.Cleanup(server.Close)
conn, err := NewHttpConnector(context.Background(), server.URL)
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
resultCh := make(chan fetchReportResult, 2)
start := time.Now()
conn.FetchReport("", 12, func(rep report.Report, err error) {
resultCh <- fetchReportResult{report: rep, err: err}
})
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("FetchReport() elapsed = %v, want quick return", elapsed)
}
select {
case req := <-requestCh:
if req.path != "/api/v1/report" {
t.Fatalf("request path = %q, want %q", req.path, "/api/v1/report")
}
if got := req.query.Get("player"); got != fetchReportPlayer {
t.Fatalf("request player = %q, want %q", got, fetchReportPlayer)
}
if got := req.query.Get("turn"); got != "12" {
t.Fatalf("request turn = %q, want %q", got, "12")
}
case <-time.After(time.Second):
t.Fatal("expected request, got none")
}
select {
case result := <-resultCh:
t.Fatalf("unexpected early callback = %#v", result)
case <-time.After(100 * time.Millisecond):
}
close(releaseResponse)
select {
case result := <-resultCh:
if result.err != nil {
t.Fatalf("FetchReport() error = %v, want nil", result.err)
}
if result.report.Turn != 12 {
t.Fatalf("FetchReport() report turn = %d, want %d", result.report.Turn, 12)
}
case <-time.After(time.Second):
t.Fatal("FetchReport() callback was not called")
}
select {
case extra := <-resultCh:
t.Fatalf("FetchReport() callback called more than once: %#v", extra)
case <-time.After(100 * time.Millisecond):
}
}
// TestDownloadVersion verifies artifact download behavior for relative and absolute URLs.
func TestDownloadVersion(t *testing.T) {
tests := []downloadVersionCase{
{
name: "relative path uses backend URL",
setup: func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T)) {
pathCh := make(chan string, 1)
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
pathCh <- r.URL.Path
w.WriteHeader(stdhttp.StatusOK)
_, _ = w.Write([]byte("artifact"))
}))
t.Cleanup(server.Close)
conn, err := NewHttpConnector(context.Background(), server.URL+"/base")
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
return conn, "downloads/client.bin", pathCh, func(t *testing.T) {}
},
want: []byte("artifact"),
wantPath: "/base/downloads/client.bin",
},
{
name: "fully qualified URL bypasses backend URL",
setup: func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T)) {
backendPathCh := make(chan string, 1)
backendServer := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
backendPathCh <- r.URL.Path
w.WriteHeader(stdhttp.StatusInternalServerError)
}))
t.Cleanup(backendServer.Close)
downloadPathCh := make(chan string, 1)
downloadServer := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
downloadPathCh <- r.URL.Path
w.WriteHeader(stdhttp.StatusOK)
_, _ = w.Write([]byte("external-artifact"))
}))
t.Cleanup(downloadServer.Close)
conn, err := NewHttpConnector(context.Background(), backendServer.URL+"/base")
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
return conn, downloadServer.URL + "/artifact.bin", downloadPathCh, func(t *testing.T) {
t.Helper()
select {
case gotPath := <-backendPathCh:
t.Fatalf("unexpected backend request path = %q", gotPath)
default:
}
}
},
want: []byte("external-artifact"),
wantPath: "/artifact.bin",
},
{
name: "non-200 status returns error",
setup: func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T)) {
pathCh := make(chan string, 1)
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
pathCh <- r.URL.Path
w.WriteHeader(stdhttp.StatusBadGateway)
}))
t.Cleanup(server.Close)
conn, err := NewHttpConnector(context.Background(), server.URL)
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
return conn, "downloads/client.bin", pathCh, func(t *testing.T) {}
},
wantErr: true,
wantPath: "/downloads/client.bin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conn, urlOrPath, pathCh, verify := tt.setup(t)
got, err := conn.DownloadVersion(urlOrPath)
if tt.wantErr {
if err == nil {
t.Fatal("DownloadVersion() error = nil, want non-nil")
}
} else {
if err != nil {
t.Fatalf("DownloadVersion() error = %v, want nil", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("DownloadVersion() = %q, want %q", got, tt.want)
}
}
select {
case gotPath := <-pathCh:
if gotPath != tt.wantPath {
t.Fatalf("request path = %q, want %q", gotPath, tt.wantPath)
}
default:
t.Fatalf("expected request path %q, got no request", tt.wantPath)
}
verify(t)
})
}
}
// TestDoRequestUsesPassedContext verifies request context is provided by caller.
func TestDoRequestUsesPassedContext(t *testing.T) {
conn, pathCh := newServerConnector(t, context.Background(), stdhttp.StatusOK, "")
@@ -247,6 +622,208 @@ func TestDoRequestUsesPassedContext(t *testing.T) {
}
}
// TestDoRequestMovedPermanentlyRedirectsRelative verifies 301 responses follow relative Location redirects.
func TestDoRequestMovedPermanentlyRedirectsRelative(t *testing.T) {
requestPaths := make(chan string, 2)
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
requestPaths <- r.URL.Path
if r.URL.Path == "/base/api/v1/status" {
w.Header().Set("Location", "/redirected")
w.WriteHeader(stdhttp.StatusMovedPermanently)
return
}
w.WriteHeader(stdhttp.StatusOK)
}))
t.Cleanup(server.Close)
conn, err := NewHttpConnector(context.Background(), server.URL+"/base")
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
resp, err := conn.doRequest(context.Background(), checkConnectionPath)
if err != nil {
t.Fatalf("doRequest() error = %v, want nil", err)
}
defer resp.Body.Close()
if resp.StatusCode != stdhttp.StatusOK {
t.Fatalf("doRequest() status code = %d, want %d", resp.StatusCode, stdhttp.StatusOK)
}
gotFirstPath := <-requestPaths
if gotFirstPath != "/base/api/v1/status" {
t.Fatalf("first request path = %q, want %q", gotFirstPath, "/base/api/v1/status")
}
gotSecondPath := <-requestPaths
if gotSecondPath != "/redirected" {
t.Fatalf("redirect request path = %q, want %q", gotSecondPath, "/redirected")
}
}
// TestDoRequestMovedPermanentlyRedirectsAbsolute verifies 301 responses follow absolute Location redirects.
func TestDoRequestMovedPermanentlyRedirectsAbsolute(t *testing.T) {
initialPaths := make(chan string, 1)
redirectPaths := make(chan string, 1)
redirectServer := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
redirectPaths <- r.URL.Path
w.WriteHeader(stdhttp.StatusOK)
}))
t.Cleanup(redirectServer.Close)
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
initialPaths <- r.URL.Path
w.Header().Set("Location", redirectServer.URL+"/redirected")
w.WriteHeader(stdhttp.StatusMovedPermanently)
}))
t.Cleanup(server.Close)
conn, err := NewHttpConnector(context.Background(), server.URL)
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
resp, err := conn.doRequest(context.Background(), checkConnectionPath)
if err != nil {
t.Fatalf("doRequest() error = %v, want nil", err)
}
defer resp.Body.Close()
if resp.StatusCode != stdhttp.StatusOK {
t.Fatalf("doRequest() status code = %d, want %d", resp.StatusCode, stdhttp.StatusOK)
}
select {
case gotPath := <-initialPaths:
if gotPath != "/api/v1/status" {
t.Fatalf("initial request path = %q, want %q", gotPath, "/api/v1/status")
}
default:
t.Fatal("expected initial request, got none")
}
select {
case gotPath := <-redirectPaths:
if gotPath != "/redirected" {
t.Fatalf("redirect request path = %q, want %q", gotPath, "/redirected")
}
default:
t.Fatal("expected redirect request, got none")
}
}
// TestDoRequestMovedPermanentlyWithoutLocation verifies invalid 301 responses return an error.
func TestDoRequestMovedPermanentlyWithoutLocation(t *testing.T) {
conn, _ := newServerConnector(t, context.Background(), stdhttp.StatusMovedPermanently, "")
_, err := conn.doRequest(context.Background(), checkConnectionPath)
if err == nil {
t.Fatal("doRequest() error = nil, want non-nil")
}
if !errors.Is(err, errMovedPermanentlyWithoutLocation) {
t.Fatalf("doRequest() error = %v, want %v", err, errMovedPermanentlyWithoutLocation)
}
}
// TestDoRequestMovedPermanentlyUsesRetryBudget verifies redirect follow-up requests consume jittered attempts.
func TestDoRequestMovedPermanentlyUsesRetryBudget(t *testing.T) {
attempts := 0
jitterCaps := make([]time.Duration, 0)
sleepDurations := make([]time.Duration, 0)
conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
attempts++
switch req.URL.String() {
case "http://example.com/api/v1/status":
return &stdhttp.Response{
StatusCode: stdhttp.StatusMovedPermanently,
Header: stdhttp.Header{
"Location": []string{"/redirected"},
},
Body: io.NopCloser(strings.NewReader("")),
}, nil
case "http://example.com/redirected":
return &stdhttp.Response{
StatusCode: stdhttp.StatusOK,
Body: io.NopCloser(strings.NewReader("")),
}, nil
default:
t.Fatalf("unexpected request URL = %q", req.URL.String())
return nil, nil
}
})
conn.jitterFn = func(cap time.Duration) time.Duration {
jitterCaps = append(jitterCaps, cap)
return cap
}
conn.sleepFn = func(ctx context.Context, d time.Duration) error {
sleepDurations = append(sleepDurations, d)
return nil
}
resp, err := conn.doRequest(context.Background(), checkConnectionPath)
if err != nil {
t.Fatalf("doRequest() error = %v, want nil", err)
}
defer resp.Body.Close()
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
wantCaps := []time.Duration{5 * time.Second}
if !reflect.DeepEqual(jitterCaps, wantCaps) {
t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps)
}
if !reflect.DeepEqual(sleepDurations, wantCaps) {
t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps)
}
}
// TestDoRequestMovedPermanentlyExhaustsRetryBudget verifies repeated redirects eventually fail.
func TestDoRequestMovedPermanentlyExhaustsRetryBudget(t *testing.T) {
attempts := 0
jitterCaps := make([]time.Duration, 0)
sleepDurations := make([]time.Duration, 0)
conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
attempts++
return &stdhttp.Response{
StatusCode: stdhttp.StatusMovedPermanently,
Header: stdhttp.Header{
"Location": []string{"/redirected"},
},
Body: io.NopCloser(strings.NewReader("")),
}, nil
})
conn.jitterFn = func(cap time.Duration) time.Duration {
jitterCaps = append(jitterCaps, cap)
return cap
}
conn.sleepFn = func(ctx context.Context, d time.Duration) error {
sleepDurations = append(sleepDurations, d)
return nil
}
_, err := conn.doRequest(context.Background(), checkConnectionPath)
if err == nil {
t.Fatal("doRequest() error = nil, want non-nil")
}
wantCaps := append([]time.Duration(nil), defaultRetryCaps...)
if attempts != len(wantCaps)+1 {
t.Fatalf("attempts = %d, want %d", attempts, len(wantCaps)+1)
}
if !reflect.DeepEqual(jitterCaps, wantCaps) {
t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps)
}
if !reflect.DeepEqual(sleepDurations, wantCaps) {
t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps)
}
}
// TestDoRequestResponseHeaderTimeout verifies client distinguishes response timeout.
func TestDoRequestResponseHeaderTimeout(t *testing.T) {
const (
@@ -574,6 +1151,26 @@ func newVersionServerConnector(t *testing.T, ctx context.Context, status int, bo
return conn, pathCh
}
// newFetchReportServerConnector creates connector with configurable response body for report endpoint tests.
func newFetchReportServerConnector(t *testing.T, ctx context.Context, status int, body, backendPath string) (*httpConnector, <-chan requestDetails) {
t.Helper()
requestCh := make(chan requestDetails, 1)
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
requestCh <- requestDetails{path: r.URL.Path, query: r.URL.Query()}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
conn, err := NewHttpConnector(ctx, server.URL+backendPath)
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
return conn, requestCh
}
// newUnreachableConnector creates connector pointing to a closed localhost TCP address.
func newUnreachableConnector(t *testing.T, ctx context.Context) *httpConnector {
t.Helper()