fs storage
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"galaxy/model/client"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
// Connector is a main interface to provide connectivity with app's server.
|
||||
type Connector interface {
|
||||
UIConnector
|
||||
|
||||
// CheckConnection is called asynchronously every 5 seconds and tests is connection available with a specific backend server endpoint.
|
||||
// There is guaranteed jittered backoff with caps 5s -> 15s -> 30s -> 60s when no connection is available.
|
||||
CheckConnection() bool
|
||||
|
||||
// CheckVersion is called asynchronously every 30 minutes and receives from backend server information about currently available app versions.
|
||||
CheckVersion() ([]VersionInfo, error)
|
||||
|
||||
// DownloadVersion asynchronously retrieves from a specific string URL a binary artifact from backend server.
|
||||
DownloadVersion(string) ([]byte, error)
|
||||
}
|
||||
|
||||
// UIConnector contains only funcs are needed for the client app to be functional.
|
||||
type UIConnector interface {
|
||||
// FetchReport asynchronously requests from backend server a [report.Report] for a given [model.GameID] and turn number.
|
||||
// Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried,
|
||||
// otherwise callback func accepts loaded [report.Report].
|
||||
FetchReport(context.Context, client.GameID, uint, func(report.Report, error))
|
||||
}
|
||||
|
||||
type VersionInfo struct {
|
||||
OS string `json:"os"` // Operating System name (unix, darwin, windows, etc.)
|
||||
Version string `json:"version"` // Semver format: X.Y.Z
|
||||
URL string `json:"url"` // Artifact download URL for this version
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module galaxy/connector
|
||||
|
||||
go 1.26.0
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package implements "galaxy/connector.Connector" interface with HTTP REST API protocol
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/connector"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// checkConnectionPath is backend endpoint path used to test server reachability.
|
||||
checkConnectionPath = "api/v1/status"
|
||||
// checkVersionPath is backend endpoint path used to load available app versions.
|
||||
checkVersionPath = "api/v1/versions"
|
||||
|
||||
// connectTimeout is max time for establishing TCP connection.
|
||||
connectTimeout = 3 * time.Second
|
||||
// responseTimeout is max time for waiting response headers from backend.
|
||||
responseTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// defaultRetryCaps defines connect-timeout retry caps for full-jitter backoff.
|
||||
var defaultRetryCaps = []time.Duration{
|
||||
5 * time.Second,
|
||||
15 * time.Second,
|
||||
30 * time.Second,
|
||||
60 * time.Second,
|
||||
}
|
||||
|
||||
type httpConnector struct {
|
||||
ctx context.Context
|
||||
backendURL *url.URL // HTTP REST API Server URL
|
||||
httpClient *http.Client
|
||||
retryCaps []time.Duration
|
||||
jitterFn func(time.Duration) time.Duration
|
||||
sleepFn func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) {
|
||||
u, err := url.Parse(backendURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := &httpConnector{
|
||||
ctx: ctx,
|
||||
backendURL: u,
|
||||
httpClient: newHTTPClient(connectTimeout, responseTimeout),
|
||||
retryCaps: append([]time.Duration(nil), defaultRetryCaps...),
|
||||
jitterFn: fullJitter,
|
||||
sleepFn: sleepWithContext,
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// newHTTPClient builds dedicated HTTP client with separate timeouts
|
||||
// for connect and response phases.
|
||||
func newHTTPClient(connectTimeout, responseTimeout time.Duration) *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.DialContext = (&net.Dialer{
|
||||
Timeout: connectTimeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext
|
||||
transport.TLSHandshakeTimeout = connectTimeout
|
||||
transport.ResponseHeaderTimeout = responseTimeout
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *httpConnector) requestContext() context.Context {
|
||||
if h.ctx != nil {
|
||||
return h.ctx
|
||||
}
|
||||
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// fullJitter calculates random wait duration in [0, cap].
|
||||
func fullJitter(cap time.Duration) time.Duration {
|
||||
if cap <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return time.Duration(rand.Int64N(cap.Nanoseconds() + 1))
|
||||
}
|
||||
|
||||
// sleepWithContext blocks for the given duration or until context cancellation.
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isConnectTimeout returns true for dial and TLS-handshake timeout errors.
|
||||
func isConnectTimeout(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
err = urlErr.Err
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), "timeout awaiting response headers") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), "TLS handshake timeout") {
|
||||
return true
|
||||
}
|
||||
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
return opErr.Op == "dial" && opErr.Timeout()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckConnection probes backend status endpoint and reports whether server is reachable.
|
||||
func (h *httpConnector) CheckConnection() bool {
|
||||
resp, err := h.doRequest(h.requestContext(), checkConnectionPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CheckVersion loads available app versions from backend and returns parsed version metadata.
|
||||
func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
|
||||
resp, err := h.doRequest(h.requestContext(), checkVersionPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request versions from backend: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var versions []connector.VersionInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
|
||||
return nil, fmt.Errorf("decode versions response: %w", err)
|
||||
}
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// doRequest performs GET request for a backend relative endpoint with passed context.
|
||||
func (h *httpConnector) doRequest(ctx context.Context, relativePath string) (*http.Response, error) {
|
||||
requestURL := *h.backendURL
|
||||
requestURL.Path = path.Join(requestURL.Path, relativePath)
|
||||
|
||||
retryCaps := h.retryCaps
|
||||
if retryCaps == nil {
|
||||
retryCaps = defaultRetryCaps
|
||||
}
|
||||
jitterFn := h.jitterFn
|
||||
if jitterFn == nil {
|
||||
jitterFn = fullJitter
|
||||
}
|
||||
sleepFn := h.sleepFn
|
||||
if sleepFn == nil {
|
||||
sleepFn = sleepWithContext
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(retryCaps); attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := jitterFn(retryCaps[attempt-1])
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
if err := sleepFn(ctx, delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if !isConnectTimeout(err) {
|
||||
return nil, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"galaxy/connector"
|
||||
"io"
|
||||
"net"
|
||||
stdhttp "net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checkConnectionCase describes one CheckConnection behavior scenario.
|
||||
type checkConnectionCase struct {
|
||||
name string
|
||||
setup func(t *testing.T) (*httpConnector, <-chan string)
|
||||
want bool
|
||||
wantPath string
|
||||
}
|
||||
|
||||
// checkVersionCase describes one CheckVersion behavior scenario.
|
||||
type checkVersionCase struct {
|
||||
name string
|
||||
setup func(t *testing.T) (*httpConnector, <-chan string)
|
||||
want []connector.VersionInfo
|
||||
wantErr bool
|
||||
wantPath string
|
||||
}
|
||||
|
||||
// TestCheckConnection verifies backend reachability probe behavior.
|
||||
func TestCheckConnection(t *testing.T) {
|
||||
tests := []checkConnectionCase{
|
||||
{
|
||||
name: "status 200 returns true",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newServerConnector(t, context.Background(), stdhttp.StatusOK, "")
|
||||
},
|
||||
want: true,
|
||||
wantPath: "/api/v1/status",
|
||||
},
|
||||
{
|
||||
name: "non-2xx status returns true",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newServerConnector(t, context.Background(), stdhttp.StatusServiceUnavailable, "")
|
||||
},
|
||||
want: true,
|
||||
wantPath: "/api/v1/status",
|
||||
},
|
||||
{
|
||||
name: "canceled context returns false",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
conn, err := NewHttpConnector(ctx, "http://127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("NewHttpConnector() error = %v", err)
|
||||
}
|
||||
return conn, nil
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "transport failure returns false",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newUnreachableConnector(t, context.Background()), nil
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "backend path prefix is preserved",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newServerConnector(t, context.Background(), stdhttp.StatusOK, "/base")
|
||||
},
|
||||
want: true,
|
||||
wantPath: "/base/api/v1/status",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
conn, pathCh := tt.setup(t)
|
||||
|
||||
got := conn.CheckConnection()
|
||||
if got != tt.want {
|
||||
t.Fatalf("CheckConnection() = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
if tt.wantPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckVersion verifies versions retrieval behavior.
|
||||
func TestCheckVersion(t *testing.T) {
|
||||
tests := []checkVersionCase{
|
||||
{
|
||||
name: "status 200 with valid body returns versions",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newVersionServerConnector(
|
||||
t,
|
||||
context.Background(),
|
||||
stdhttp.StatusOK,
|
||||
`[{"os":"darwin","version":"1.2.3","url":"https://example.com/darwin"}]`,
|
||||
"",
|
||||
)
|
||||
},
|
||||
want: []connector.VersionInfo{
|
||||
{OS: "darwin", Version: "1.2.3", URL: "https://example.com/darwin"},
|
||||
},
|
||||
wantPath: "/api/v1/versions",
|
||||
},
|
||||
{
|
||||
name: "status 200 with invalid json returns error",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newVersionServerConnector(
|
||||
t,
|
||||
context.Background(),
|
||||
stdhttp.StatusOK,
|
||||
`{"versions":`,
|
||||
"",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantPath: "/api/v1/versions",
|
||||
},
|
||||
{
|
||||
name: "non-200 status returns error",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newVersionServerConnector(
|
||||
t,
|
||||
context.Background(),
|
||||
stdhttp.StatusServiceUnavailable,
|
||||
`[]`,
|
||||
"",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantPath: "/api/v1/versions",
|
||||
},
|
||||
{
|
||||
name: "canceled context returns error",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
conn, err := NewHttpConnector(ctx, "http://127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("NewHttpConnector() error = %v", err)
|
||||
}
|
||||
return conn, nil
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "transport failure returns error",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newUnreachableConnector(t, context.Background()), nil
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "backend path prefix is preserved",
|
||||
setup: func(t *testing.T) (*httpConnector, <-chan string) {
|
||||
return newVersionServerConnector(
|
||||
t,
|
||||
context.Background(),
|
||||
stdhttp.StatusOK,
|
||||
`[{"os":"linux","version":"2.0.0","url":"https://example.com/linux"}]`,
|
||||
"/base",
|
||||
)
|
||||
},
|
||||
want: []connector.VersionInfo{
|
||||
{OS: "linux", Version: "2.0.0", URL: "https://example.com/linux"},
|
||||
},
|
||||
wantPath: "/base/api/v1/versions",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
conn, pathCh := tt.setup(t)
|
||||
|
||||
got, err := conn.CheckVersion()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("CheckVersion() error = nil, want non-nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("CheckVersion() error = %v, want nil", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("CheckVersion() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.wantPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoRequestUsesPassedContext verifies request context is provided by caller.
|
||||
func TestDoRequestUsesPassedContext(t *testing.T) {
|
||||
conn, pathCh := newServerConnector(t, context.Background(), stdhttp.StatusOK, "")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := conn.doRequest(ctx, checkConnectionPath)
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() error = nil, want non-nil")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("doRequest() error = %v, want context canceled", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case gotPath := <-pathCh:
|
||||
t.Fatalf("expected no request with canceled context, got %q", gotPath)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoRequestResponseHeaderTimeout verifies client distinguishes response timeout.
|
||||
func TestDoRequestResponseHeaderTimeout(t *testing.T) {
|
||||
const (
|
||||
dialTimeout = time.Second
|
||||
headerTimeout = 30 * time.Millisecond
|
||||
serverHeaderDelayTime = 150 * time.Millisecond
|
||||
)
|
||||
|
||||
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
time.Sleep(serverHeaderDelayTime)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
backendURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse backend URL error = %v", err)
|
||||
}
|
||||
|
||||
conn := &httpConnector{
|
||||
ctx: context.Background(),
|
||||
backendURL: backendURL,
|
||||
httpClient: newHTTPClient(dialTimeout, headerTimeout),
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = conn.doRequest(context.Background(), checkConnectionPath)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() error = nil, want timeout")
|
||||
}
|
||||
if elapsed < headerTimeout {
|
||||
t.Fatalf("doRequest() elapsed = %v, want >= %v", elapsed, headerTimeout)
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if !errors.As(err, &netErr) || !netErr.Timeout() {
|
||||
t.Fatalf("doRequest() error = %v, want timeout error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoRequestSuccessFirstAttemptNoRetry verifies successful call does not schedule retries.
|
||||
func TestDoRequestSuccessFirstAttemptNoRetry(t *testing.T) {
|
||||
attempts := 0
|
||||
sleepCalls := 0
|
||||
jitterCalls := 0
|
||||
|
||||
conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
|
||||
attempts++
|
||||
return &stdhttp.Response{
|
||||
StatusCode: stdhttp.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}, nil
|
||||
})
|
||||
conn.jitterFn = func(cap time.Duration) time.Duration {
|
||||
jitterCalls++
|
||||
return cap
|
||||
}
|
||||
conn.sleepFn = func(ctx context.Context, d time.Duration) error {
|
||||
sleepCalls++
|
||||
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 != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
if jitterCalls != 0 {
|
||||
t.Fatalf("jitter calls = %d, want 0", jitterCalls)
|
||||
}
|
||||
if sleepCalls != 0 {
|
||||
t.Fatalf("sleep calls = %d, want 0", sleepCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoRequestConnectTimeoutRetriesWithJitter verifies retries for connect timeout errors.
|
||||
func TestDoRequestConnectTimeoutRetriesWithJitter(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++
|
||||
if attempts <= 2 {
|
||||
return nil, newDialTimeoutError()
|
||||
}
|
||||
return &stdhttp.Response{
|
||||
StatusCode: stdhttp.StatusOK,
|
||||
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
|
||||
}
|
||||
|
||||
resp, err := conn.doRequest(context.Background(), checkConnectionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("doRequest() error = %v, want nil", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
|
||||
wantCaps := []time.Duration{5 * time.Second, 15 * 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoRequestConnectTimeoutExhaustsRetries verifies retry count and final timeout error.
|
||||
func TestDoRequestConnectTimeoutExhaustsRetries(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 nil, newDialTimeoutError()
|
||||
})
|
||||
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 timeout")
|
||||
}
|
||||
if !isConnectTimeout(err) {
|
||||
t.Fatalf("doRequest() error = %v, want connect timeout", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoRequestResponseTimeoutNoRetry verifies response timeout does not trigger retries.
|
||||
func TestDoRequestResponseTimeoutNoRetry(t *testing.T) {
|
||||
attempts := 0
|
||||
sleepCalls := 0
|
||||
jitterCalls := 0
|
||||
|
||||
conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
|
||||
attempts++
|
||||
return nil, newResponseHeaderTimeoutError()
|
||||
})
|
||||
conn.jitterFn = func(cap time.Duration) time.Duration {
|
||||
jitterCalls++
|
||||
return cap
|
||||
}
|
||||
conn.sleepFn = func(ctx context.Context, d time.Duration) error {
|
||||
sleepCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := conn.doRequest(context.Background(), checkConnectionPath)
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() error = nil, want timeout")
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if !errors.As(err, &netErr) || !netErr.Timeout() {
|
||||
t.Fatalf("doRequest() error = %v, want timeout error", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
if jitterCalls != 0 {
|
||||
t.Fatalf("jitter calls = %d, want 0", jitterCalls)
|
||||
}
|
||||
if sleepCalls != 0 {
|
||||
t.Fatalf("sleep calls = %d, want 0", sleepCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoRequestContextCanceledDuringBackoff verifies cancellation interrupts retry wait.
|
||||
func TestDoRequestContextCanceledDuringBackoff(t *testing.T) {
|
||||
attempts := 0
|
||||
sleepCalls := 0
|
||||
|
||||
conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
|
||||
attempts++
|
||||
return nil, newDialTimeoutError()
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
conn.jitterFn = func(cap time.Duration) time.Duration {
|
||||
return cap
|
||||
}
|
||||
conn.sleepFn = func(ctx context.Context, d time.Duration) error {
|
||||
sleepCalls++
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
_, err := conn.doRequest(ctx, checkConnectionPath)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("doRequest() error = %v, want context canceled", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
if sleepCalls != 1 {
|
||||
t.Fatalf("sleep calls = %d, want 1", sleepCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(req *stdhttp.Request) (*stdhttp.Response, error)
|
||||
|
||||
// RoundTrip implements [http.RoundTripper].
|
||||
func (f roundTripperFunc) RoundTrip(req *stdhttp.Request) (*stdhttp.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
// timeoutError simulates a timeout error returned by transport.
|
||||
type timeoutError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e timeoutError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e timeoutError) Timeout() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (e timeoutError) Temporary() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// newDialTimeoutError builds a connect timeout shaped like dial failure.
|
||||
func newDialTimeoutError() error {
|
||||
return &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: timeoutError{message: "i/o timeout"},
|
||||
}
|
||||
}
|
||||
|
||||
// newResponseHeaderTimeoutError builds timeout error for response header wait.
|
||||
func newResponseHeaderTimeoutError() error {
|
||||
return timeoutError{message: "net/http: timeout awaiting response headers"}
|
||||
}
|
||||
|
||||
// newTransportConnector creates connector with custom round tripper for request tests.
|
||||
func newTransportConnector(t *testing.T, transport roundTripperFunc) *httpConnector {
|
||||
t.Helper()
|
||||
|
||||
backendURL, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("parse backend URL error = %v", err)
|
||||
}
|
||||
|
||||
return &httpConnector{
|
||||
ctx: context.Background(),
|
||||
backendURL: backendURL,
|
||||
httpClient: &stdhttp.Client{Transport: transport},
|
||||
retryCaps: append([]time.Duration(nil), defaultRetryCaps...),
|
||||
}
|
||||
}
|
||||
|
||||
// newServerConnector creates connector backed by an HTTP test server and captures requested path.
|
||||
func newServerConnector(t *testing.T, ctx context.Context, status int, backendPath string) (*httpConnector, <-chan string) {
|
||||
t.Helper()
|
||||
|
||||
pathCh := make(chan string, 1)
|
||||
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
pathCh <- r.URL.Path
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
conn, err := NewHttpConnector(ctx, server.URL+backendPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHttpConnector() error = %v", err)
|
||||
}
|
||||
|
||||
return conn, pathCh
|
||||
}
|
||||
|
||||
// newVersionServerConnector creates connector with configurable response body for versions endpoint tests.
|
||||
func newVersionServerConnector(t *testing.T, ctx context.Context, status int, body, backendPath string) (*httpConnector, <-chan string) {
|
||||
t.Helper()
|
||||
|
||||
pathCh := make(chan string, 1)
|
||||
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
pathCh <- r.URL.Path
|
||||
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, pathCh
|
||||
}
|
||||
|
||||
// newUnreachableConnector creates connector pointing to a closed localhost TCP address.
|
||||
func newUnreachableConnector(t *testing.T, ctx context.Context) *httpConnector {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen error = %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Fatalf("close listener error = %v", err)
|
||||
}
|
||||
|
||||
conn, err := NewHttpConnector(ctx, "http://"+addr)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHttpConnector() error = %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
+680
-51
@@ -1,75 +1,704 @@
|
||||
// fs implements galaxy/storage.Storage with filesystem
|
||||
// Package fs implements galaxy/storage.Storage using the filesystem.
|
||||
package fs
|
||||
|
||||
/*
|
||||
|
||||
Общие правила:
|
||||
|
||||
1. Все хранимые объекты сериализуются / десериализуются как JSON.
|
||||
|
||||
2. Структура хранения файлов:
|
||||
|
||||
- storageRoot \
|
||||
|
|
||||
+-- state.dat
|
||||
|
|
||||
+-- {GameID} \
|
||||
| |
|
||||
| +-- {Turn}.dat (client.GameData)
|
||||
| +-- {Turn}.dat (client.GameData)
|
||||
| +-- ...
|
||||
|
|
||||
+-- {GameID} \
|
||||
|
|
||||
+-- ...
|
||||
|
||||
*/
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/util"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"galaxy/model/client"
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/report"
|
||||
"galaxy/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// Name of the file under the storage's root where [model.State] is stored.
|
||||
// stateFileName is the file name under the storage root where [client.State] is stored.
|
||||
stateFileName = "state.dat"
|
||||
|
||||
// Suffix of a Game's file inder the storage's root where [model.GameData] is stored.
|
||||
// gameDataFileSuffix is the extension for per-turn [client.GameData] files.
|
||||
gameDataFileSuffix = ".dat"
|
||||
|
||||
defaultFilePerm = 0o644
|
||||
oldFileSuffix = ".old"
|
||||
newFileSuffix = ".new"
|
||||
)
|
||||
|
||||
// StateFilePath returns client's state file path relative to the root,
|
||||
// file name and extension are pre-defined constant.
|
||||
// StateFilePath returns the path to the persisted [client.State] file under root.
|
||||
func StateFilePath(root string) string {
|
||||
return filepath.Join(root, stateFileName)
|
||||
}
|
||||
|
||||
// GameDataPath returns game's data file path relative to the root,
|
||||
// data file name is GameID string representation and extension is a pre-defined constant.
|
||||
// GameDataFilePath returns the legacy per-game data file path under root.
|
||||
//
|
||||
// The storage implementation keeps turn data in per-turn files under a game directory
|
||||
// and does not use this helper internally.
|
||||
func GameDataFilePath(root string, id fmt.Stringer) string {
|
||||
return filepath.Join(root, id.String()) + gameDataFileSuffix
|
||||
}
|
||||
|
||||
type fsStorage struct {
|
||||
storageRoot string
|
||||
type pathLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
// NewFS returns on-filesystem implementation of the "galaxy/storage.Storage" with root located at storageRoot.
|
||||
// storageRoot must me a directory and has write access to the current user. If initial checks failed, return nil and non-nil error.
|
||||
func NewStorage(storageRoot string) (*fsStorage, error) {
|
||||
if ok, err := util.PathExists(storageRoot, true); err != nil {
|
||||
return nil, fmt.Errorf("new storage: check path %q exists: %w", storageRoot, err)
|
||||
} else if !ok {
|
||||
return nil, fmt.Errorf("new storage: path %q does not exists", storageRoot)
|
||||
}
|
||||
if ok, err := util.Writable(storageRoot); err != nil {
|
||||
return nil, fmt.Errorf("new storage: check path %q writable: %w", storageRoot, err)
|
||||
} else if !ok {
|
||||
return nil, fmt.Errorf("new storage: path %q is not writable", storageRoot)
|
||||
}
|
||||
s := &fsStorage{
|
||||
storageRoot: storageRoot,
|
||||
}
|
||||
return s, nil
|
||||
type fsStorage struct {
|
||||
storageRoot string
|
||||
|
||||
locksMu sync.Mutex
|
||||
locks map[string]*pathLock
|
||||
|
||||
readFileFn func(string) ([]byte, error)
|
||||
writeFileFn func(string, []byte, os.FileMode) error
|
||||
renameFileFn func(string, string) error
|
||||
removeFileFn func(string) error
|
||||
}
|
||||
|
||||
type storedOrder struct {
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
Commands []json.RawMessage `json:"cmd"`
|
||||
}
|
||||
|
||||
type storedGameData struct {
|
||||
Turn uint `json:"turn"`
|
||||
Report report.Report `json:"report"`
|
||||
Order *storedOrder `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// NewFS returns a filesystem-backed implementation of galaxy/storage.Storage rooted at storageRoot.
|
||||
// storageRoot must already exist, be a directory, and be writable by the current user.
|
||||
func NewFS(storageRoot string) (*fsStorage, error) {
|
||||
fmt.Println("using fs root:", storageRoot)
|
||||
absRoot, err := filepath.Abs(storageRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err)
|
||||
}
|
||||
if ok, err := util.PathExists(absRoot, true); err != nil {
|
||||
return nil, fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err)
|
||||
} else if !ok {
|
||||
return nil, fmt.Errorf("new fs storage: path %q does not exist", absRoot)
|
||||
}
|
||||
if ok, err := util.Writable(absRoot); err != nil {
|
||||
return nil, fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err)
|
||||
} else if !ok {
|
||||
return nil, fmt.Errorf("new fs storage: path %q is not writable", absRoot)
|
||||
}
|
||||
|
||||
return &fsStorage{
|
||||
storageRoot: absRoot,
|
||||
locks: make(map[string]*pathLock),
|
||||
readFileFn: os.ReadFile,
|
||||
writeFileFn: os.WriteFile,
|
||||
renameFileFn: os.Rename,
|
||||
removeFileFn: os.Remove,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) StateExists(callback func(bool, error)) {
|
||||
go func() {
|
||||
exists, err := s.FileExists(stateFileName)
|
||||
if callback != nil {
|
||||
callback(exists, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *fsStorage) LoadState(callback func(client.State, error)) {
|
||||
go func() {
|
||||
state, err := s.loadStateSync()
|
||||
if callback != nil {
|
||||
callback(state, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *fsStorage) SaveState(state client.State, callback func(error)) {
|
||||
go func() {
|
||||
err := s.saveStateSync(state)
|
||||
if callback != nil {
|
||||
callback(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *fsStorage) LoadReport(id client.GameID, turn uint, callback func(report.Report, error)) {
|
||||
go func() {
|
||||
rep, err := s.loadReportSync(id, turn)
|
||||
if callback != nil {
|
||||
callback(rep, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *fsStorage) SaveReport(id client.GameID, turn uint, rep report.Report, callback func(error)) {
|
||||
go func() {
|
||||
err := s.saveReportSync(id, turn, rep)
|
||||
if callback != nil {
|
||||
callback(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *fsStorage) LoadOrder(id client.GameID, turn uint, callback func(order.Order, error)) {
|
||||
go func() {
|
||||
o, err := s.loadOrderSync(id, turn)
|
||||
if callback != nil {
|
||||
callback(o, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *fsStorage) SaveOrder(id client.GameID, turn uint, o order.Order, callback func(error)) {
|
||||
go func() {
|
||||
err := s.saveOrderSync(id, turn, o)
|
||||
if callback != nil {
|
||||
callback(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *fsStorage) FileExists(path string) (bool, error) {
|
||||
absPath, err := s.resolvePath(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err = s.withPathLock(absPath, func() error {
|
||||
var opErr error
|
||||
exists, opErr = s.fileExistsUnlocked(absPath)
|
||||
return opErr
|
||||
})
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *fsStorage) ReadFile(path string) ([]byte, error) {
|
||||
absPath, err := s.resolvePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data []byte
|
||||
err = s.withPathLock(absPath, func() error {
|
||||
var opErr error
|
||||
data, opErr = s.readFileUnlocked(absPath)
|
||||
return opErr
|
||||
})
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (s *fsStorage) WriteFile(path string, data []byte) error {
|
||||
absPath, err := s.resolvePath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.withPathLock(absPath, func() error {
|
||||
return s.writeFileUnlocked(absPath, data)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fsStorage) DeleteFile(path string) error {
|
||||
absPath, err := s.resolvePath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.withPathLock(absPath, func() error {
|
||||
exists, err := s.fileExistsUnlocked(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("delete file %q: %w", absPath, os.ErrNotExist)
|
||||
}
|
||||
if err := s.removeFileFn(absPath); err != nil {
|
||||
return fmt.Errorf("delete file %q: %w", absPath, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fsStorage) ListFiles() ([]string, error) {
|
||||
files := make([]string, 0)
|
||||
err := filepath.WalkDir(s.storageRoot, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(d.Name(), newFileSuffix) || strings.HasSuffix(d.Name(), oldFileSuffix) {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(s.storageRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve relative path for %q: %w", path, err)
|
||||
}
|
||||
files = append(files, filepath.Clean(relPath))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list files under %q: %w", s.storageRoot, err)
|
||||
}
|
||||
|
||||
slices.Sort(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) loadStateSync() (client.State, error) {
|
||||
data, err := s.ReadFile(stateFileName)
|
||||
if err != nil {
|
||||
return client.State{}, err
|
||||
}
|
||||
return unmarshalState(data)
|
||||
}
|
||||
|
||||
func (s *fsStorage) saveStateSync(state client.State) error {
|
||||
data, err := marshalState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteFile(stateFileName, data)
|
||||
}
|
||||
|
||||
func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report, error) {
|
||||
gameData, err := s.loadGameDataSync(id, turn)
|
||||
if err != nil {
|
||||
return report.Report{}, err
|
||||
}
|
||||
return gameData.Report, nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Report) error {
|
||||
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.withPathLock(absPath, func() error {
|
||||
gameData, err := s.loadGameDataUnlocked(absPath)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
gameData = client.GameData{Turn: turn}
|
||||
}
|
||||
|
||||
gameData.Turn = turn
|
||||
gameData.Report = rep
|
||||
return s.writeGameDataUnlocked(absPath, gameData)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) {
|
||||
gameData, err := s.loadGameDataSync(id, turn)
|
||||
if err != nil {
|
||||
return order.Order{}, err
|
||||
}
|
||||
if gameData.Order == nil {
|
||||
return order.Order{}, fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist)
|
||||
}
|
||||
return *gameData.Order, nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) error {
|
||||
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.withPathLock(absPath, func() error {
|
||||
gameData, err := s.loadGameDataUnlocked(absPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("save order for game %q turn %d: %w", id, turn, os.ErrNotExist)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
gameData.Turn = turn
|
||||
gameData.Order = &o
|
||||
return s.writeGameDataUnlocked(absPath, gameData)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) {
|
||||
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
||||
if err != nil {
|
||||
return client.GameData{}, err
|
||||
}
|
||||
|
||||
var gameData client.GameData
|
||||
err = s.withPathLock(absPath, func() error {
|
||||
var opErr error
|
||||
gameData, opErr = s.loadGameDataUnlocked(absPath)
|
||||
return opErr
|
||||
})
|
||||
return gameData, err
|
||||
}
|
||||
|
||||
func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) {
|
||||
data, err := s.readFileUnlocked(absPath)
|
||||
if err != nil {
|
||||
return client.GameData{}, err
|
||||
}
|
||||
return unmarshalGameData(data)
|
||||
}
|
||||
|
||||
func (s *fsStorage) writeGameDataUnlocked(absPath string, gameData client.GameData) error {
|
||||
data, err := marshalGameData(gameData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeFileUnlocked(absPath, data)
|
||||
}
|
||||
|
||||
func marshalState(state client.State) ([]byte, error) {
|
||||
return marshalJSON(state)
|
||||
}
|
||||
|
||||
func unmarshalState(data []byte) (client.State, error) {
|
||||
var state client.State
|
||||
if err := unmarshalJSON(data, &state); err != nil {
|
||||
return client.State{}, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func marshalGameData(gameData client.GameData) ([]byte, error) {
|
||||
stored, err := makeStoredGameData(gameData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalJSON(stored)
|
||||
}
|
||||
|
||||
func unmarshalGameData(data []byte) (client.GameData, error) {
|
||||
var stored storedGameData
|
||||
if err := unmarshalJSON(data, &stored); err != nil {
|
||||
return client.GameData{}, err
|
||||
}
|
||||
return stored.toGameData()
|
||||
}
|
||||
|
||||
func marshalJSON(value any) ([]byte, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal json: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func unmarshalJSON(data []byte, target any) error {
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return fmt.Errorf("unmarshal json: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeStoredGameData(gameData client.GameData) (storedGameData, error) {
|
||||
stored := storedGameData{
|
||||
Turn: gameData.Turn,
|
||||
Report: gameData.Report,
|
||||
}
|
||||
if gameData.Order == nil {
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
storedOrder, err := makeStoredOrder(*gameData.Order)
|
||||
if err != nil {
|
||||
return storedGameData{}, err
|
||||
}
|
||||
stored.Order = &storedOrder
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (d storedGameData) toGameData() (client.GameData, error) {
|
||||
gameData := client.GameData{
|
||||
Turn: d.Turn,
|
||||
Report: d.Report,
|
||||
}
|
||||
if d.Order == nil {
|
||||
return gameData, nil
|
||||
}
|
||||
|
||||
o, err := d.Order.toOrder()
|
||||
if err != nil {
|
||||
return client.GameData{}, err
|
||||
}
|
||||
gameData.Order = o
|
||||
return gameData, nil
|
||||
}
|
||||
|
||||
func makeStoredOrder(o order.Order) (storedOrder, error) {
|
||||
result := storedOrder{
|
||||
UpdatedAt: o.UpdatedAt,
|
||||
Commands: make([]json.RawMessage, len(o.Commands)),
|
||||
}
|
||||
for i := range o.Commands {
|
||||
data, err := marshalJSON(o.Commands[i])
|
||||
if err != nil {
|
||||
return storedOrder{}, fmt.Errorf("marshal order command %d: %w", i, err)
|
||||
}
|
||||
result.Commands[i] = data
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *storedOrder) toOrder() (*order.Order, error) {
|
||||
if o == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := &order.Order{
|
||||
UpdatedAt: o.UpdatedAt,
|
||||
Commands: make([]order.DecodableCommand, len(o.Commands)),
|
||||
}
|
||||
for i := range o.Commands {
|
||||
cmd, err := parseOrderCommand(o.Commands[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode order command %d: %w", i, err)
|
||||
}
|
||||
result.Commands[i] = cmd
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseOrderCommand(data json.RawMessage) (order.DecodableCommand, error) {
|
||||
meta := new(order.CommandMeta)
|
||||
if err := json.Unmarshal(data, meta); err != nil {
|
||||
return nil, fmt.Errorf("decode order command metadata: %w", err)
|
||||
}
|
||||
|
||||
switch meta.CmdType {
|
||||
case order.CommandTypeRaceQuit:
|
||||
return decodeOrderCommand(data, new(order.CommandRaceQuit))
|
||||
case order.CommandTypeRaceVote:
|
||||
return decodeOrderCommand(data, new(order.CommandRaceVote))
|
||||
case order.CommandTypeRaceRelation:
|
||||
return decodeOrderCommand(data, new(order.CommandRaceRelation))
|
||||
case order.CommandTypeShipClassCreate:
|
||||
return decodeOrderCommand(data, new(order.CommandShipClassCreate))
|
||||
case order.CommandTypeShipClassMerge:
|
||||
return decodeOrderCommand(data, new(order.CommandShipClassMerge))
|
||||
case order.CommandTypeShipClassRemove:
|
||||
return decodeOrderCommand(data, new(order.CommandShipClassRemove))
|
||||
case order.CommandTypeShipGroupBreak:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupBreak))
|
||||
case order.CommandTypeShipGroupLoad:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupLoad))
|
||||
case order.CommandTypeShipGroupUnload:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupUnload))
|
||||
case order.CommandTypeShipGroupSend:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupSend))
|
||||
case order.CommandTypeShipGroupUpgrade:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupUpgrade))
|
||||
case order.CommandTypeShipGroupMerge:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupMerge))
|
||||
case order.CommandTypeShipGroupDismantle:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupDismantle))
|
||||
case order.CommandTypeShipGroupTransfer:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupTransfer))
|
||||
case order.CommandTypeShipGroupJoinFleet:
|
||||
return decodeOrderCommand(data, new(order.CommandShipGroupJoinFleet))
|
||||
case order.CommandTypeFleetMerge:
|
||||
return decodeOrderCommand(data, new(order.CommandFleetMerge))
|
||||
case order.CommandTypeFleetSend:
|
||||
return decodeOrderCommand(data, new(order.CommandFleetSend))
|
||||
case order.CommandTypeScienceCreate:
|
||||
return decodeOrderCommand(data, new(order.CommandScienceCreate))
|
||||
case order.CommandTypeScienceRemove:
|
||||
return decodeOrderCommand(data, new(order.CommandScienceRemove))
|
||||
case order.CommandTypePlanetRename:
|
||||
return decodeOrderCommand(data, new(order.CommandPlanetRename))
|
||||
case order.CommandTypePlanetProduce:
|
||||
return decodeOrderCommand(data, new(order.CommandPlanetProduce))
|
||||
case order.CommandTypePlanetRouteSet:
|
||||
return decodeOrderCommand(data, new(order.CommandPlanetRouteSet))
|
||||
case order.CommandTypePlanetRouteRemove:
|
||||
return decodeOrderCommand(data, new(order.CommandPlanetRouteRemove))
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown order command type %q", meta.CmdType)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeOrderCommand[T order.DecodableCommand](data json.RawMessage, target T) (T, error) {
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return target, err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) resolvePath(path string) (string, error) {
|
||||
relPath, err := normalizeRelativePath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(s.storageRoot, relPath), nil
|
||||
}
|
||||
|
||||
func normalizeRelativePath(path string) (string, error) {
|
||||
path = strings.ReplaceAll(path, "\\", string(filepath.Separator))
|
||||
path = strings.TrimLeft(path, string(filepath.Separator))
|
||||
path = filepath.Clean(path)
|
||||
|
||||
switch {
|
||||
case path == "." || path == "":
|
||||
return "", errors.New("path must not be empty")
|
||||
case filepath.IsAbs(path):
|
||||
return "", fmt.Errorf("path %q must be relative", path)
|
||||
case filepath.VolumeName(path) != "":
|
||||
return "", fmt.Errorf("path %q must not include a volume name", path)
|
||||
case path == "..":
|
||||
return "", fmt.Errorf("path %q escapes storage root", path)
|
||||
case strings.HasPrefix(path, ".."+string(filepath.Separator)):
|
||||
return "", fmt.Errorf("path %q escapes storage root", path)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func gameTurnFilePath(id fmt.Stringer, turn uint) string {
|
||||
return filepath.Join(id.String(), strconv.FormatUint(uint64(turn), 10)+gameDataFileSuffix)
|
||||
}
|
||||
|
||||
func (s *fsStorage) withPathLock(absPath string, fn func() error) error {
|
||||
lock := s.acquirePathLock(absPath)
|
||||
defer s.releasePathLock(absPath)
|
||||
|
||||
lock.mu.Lock()
|
||||
defer lock.mu.Unlock()
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (s *fsStorage) acquirePathLock(absPath string) *pathLock {
|
||||
s.locksMu.Lock()
|
||||
defer s.locksMu.Unlock()
|
||||
|
||||
lock, ok := s.locks[absPath]
|
||||
if !ok {
|
||||
lock = &pathLock{}
|
||||
s.locks[absPath] = lock
|
||||
}
|
||||
lock.refs++
|
||||
return lock
|
||||
}
|
||||
|
||||
func (s *fsStorage) releasePathLock(absPath string) {
|
||||
s.locksMu.Lock()
|
||||
defer s.locksMu.Unlock()
|
||||
|
||||
lock, ok := s.locks[absPath]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
lock.refs--
|
||||
if lock.refs == 0 {
|
||||
delete(s.locks, absPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fsStorage) fileExistsUnlocked(absPath string) (bool, error) {
|
||||
ok, err := util.FileExists(absPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check file %q exists: %w", absPath, err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) readFileUnlocked(absPath string) ([]byte, error) {
|
||||
data, err := s.readFileFn(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %q: %w", absPath, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) writeFileUnlocked(absPath string, data []byte) error {
|
||||
if err := s.ensureParentDir(absPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetExists, err := s.fileExistsUnlocked(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldPath := absPath + oldFileSuffix
|
||||
oldExists, err := s.fileExistsUnlocked(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if oldExists {
|
||||
return fmt.Errorf("write file %q: old file already exists at %q", absPath, oldPath)
|
||||
}
|
||||
|
||||
newPath := absPath + newFileSuffix
|
||||
newExists, err := s.fileExistsUnlocked(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newExists {
|
||||
return fmt.Errorf("write file %q: new file already exists at %q", absPath, newPath)
|
||||
}
|
||||
|
||||
if err := s.writeFileFn(newPath, data, defaultFilePerm); err != nil {
|
||||
return fmt.Errorf("write new file %q: %w", newPath, err)
|
||||
}
|
||||
|
||||
if targetExists {
|
||||
if err := s.renameFileFn(absPath, oldPath); err != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("rename file %q to %q: %w", absPath, oldPath, err),
|
||||
s.cleanupTempFile(newPath),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.renameFileFn(newPath, absPath); err != nil {
|
||||
var restoreErr error
|
||||
if targetExists {
|
||||
restoreErr = s.renameFileFn(oldPath, absPath)
|
||||
if restoreErr != nil {
|
||||
restoreErr = fmt.Errorf("restore file %q from %q: %w", absPath, oldPath, restoreErr)
|
||||
}
|
||||
}
|
||||
return errors.Join(
|
||||
fmt.Errorf("rename new file %q to %q: %w", newPath, absPath, err),
|
||||
restoreErr,
|
||||
s.cleanupTempFile(newPath),
|
||||
)
|
||||
}
|
||||
|
||||
if !targetExists {
|
||||
return nil
|
||||
}
|
||||
if err := s.removeFileFn(oldPath); err != nil {
|
||||
return fmt.Errorf("remove old file %q: %w", oldPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) cleanupTempFile(path string) error {
|
||||
if err := s.removeFileFn(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove temp file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fsStorage) ensureParentDir(absPath string) error {
|
||||
parentDir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(parentDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("create parent directory %q: %w", parentDir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/model/client"
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
const testTimeout = time.Second
|
||||
|
||||
type callbackResult[T any] struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
|
||||
func TestStateRoundTripAsync(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
want := sampleState()
|
||||
|
||||
saveDone := make(chan error, 1)
|
||||
s.SaveState(want, func(err error) {
|
||||
saveDone <- err
|
||||
})
|
||||
if err := waitError(t, saveDone); err != nil {
|
||||
t.Fatalf("save state: %v", err)
|
||||
}
|
||||
|
||||
existsDone := make(chan callbackResult[bool], 1)
|
||||
s.StateExists(func(ok bool, err error) {
|
||||
existsDone <- callbackResult[bool]{value: ok, err: err}
|
||||
})
|
||||
exists := waitResult(t, existsDone)
|
||||
if exists.err != nil {
|
||||
t.Fatalf("state exists: %v", exists.err)
|
||||
}
|
||||
if !exists.value {
|
||||
t.Fatal("state file should exist after save")
|
||||
}
|
||||
|
||||
loadDone := make(chan callbackResult[client.State], 1)
|
||||
s.LoadState(func(state client.State, err error) {
|
||||
loadDone <- callbackResult[client.State]{value: state, err: err}
|
||||
})
|
||||
got := waitResult(t, loadDone)
|
||||
if got.err != nil {
|
||||
t.Fatalf("load state: %v", got.err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.value, want) {
|
||||
t.Fatalf("loaded state mismatch\nwant: %#v\ngot: %#v", want, got.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportAndOrderRoundTripAsync(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
id := client.GameID("game-1")
|
||||
turn := uint(7)
|
||||
initialReport := sampleReport(turn, "Terran")
|
||||
updatedReport := sampleReport(turn, "Zenith")
|
||||
wantOrder := sampleOrder()
|
||||
|
||||
saveReportDone := make(chan error, 1)
|
||||
s.SaveReport(id, turn, initialReport, func(err error) {
|
||||
saveReportDone <- err
|
||||
})
|
||||
if err := waitError(t, saveReportDone); err != nil {
|
||||
t.Fatalf("save report: %v", err)
|
||||
}
|
||||
|
||||
saveOrderDone := make(chan error, 1)
|
||||
s.SaveOrder(id, turn, wantOrder, func(err error) {
|
||||
saveOrderDone <- err
|
||||
})
|
||||
if err := waitError(t, saveOrderDone); err != nil {
|
||||
t.Fatalf("save order: %v", err)
|
||||
}
|
||||
|
||||
saveUpdatedReportDone := make(chan error, 1)
|
||||
s.SaveReport(id, turn, updatedReport, func(err error) {
|
||||
saveUpdatedReportDone <- err
|
||||
})
|
||||
if err := waitError(t, saveUpdatedReportDone); err != nil {
|
||||
t.Fatalf("save updated report: %v", err)
|
||||
}
|
||||
|
||||
loadReportDone := make(chan callbackResult[report.Report], 1)
|
||||
s.LoadReport(id, turn, func(rep report.Report, err error) {
|
||||
loadReportDone <- callbackResult[report.Report]{value: rep, err: err}
|
||||
})
|
||||
gotReport := waitResult(t, loadReportDone)
|
||||
if gotReport.err != nil {
|
||||
t.Fatalf("load report: %v", gotReport.err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotReport.value, updatedReport) {
|
||||
t.Fatalf("loaded report mismatch\nwant: %#v\ngot: %#v", updatedReport, gotReport.value)
|
||||
}
|
||||
|
||||
loadOrderDone := make(chan callbackResult[order.Order], 1)
|
||||
s.LoadOrder(id, turn, func(got order.Order, err error) {
|
||||
loadOrderDone <- callbackResult[order.Order]{value: got, err: err}
|
||||
})
|
||||
gotOrder := waitResult(t, loadOrderDone)
|
||||
if gotOrder.err != nil {
|
||||
t.Fatalf("load order: %v", gotOrder.err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotOrder.value, wantOrder) {
|
||||
t.Fatalf("loaded order mismatch\nwant: %#v\ngot: %#v", wantOrder, gotOrder.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOrderBeforeReportReturnsNotExist(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
done := make(chan error, 1)
|
||||
s.SaveOrder("game-2", 3, sampleOrder(), func(err error) {
|
||||
done <- err
|
||||
})
|
||||
err := waitError(t, done)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("save order error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawFileCRUDAndList(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
if err := s.WriteFile("/nested/alpha.txt", []byte("alpha")); err != nil {
|
||||
t.Fatalf("write alpha: %v", err)
|
||||
}
|
||||
if err := s.WriteFile("beta.txt", []byte("beta")); err != nil {
|
||||
t.Fatalf("write beta: %v", err)
|
||||
}
|
||||
|
||||
alphaExists, err := s.FileExists("nested/alpha.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("file exists: %v", err)
|
||||
}
|
||||
if !alphaExists {
|
||||
t.Fatal("nested/alpha.txt should exist")
|
||||
}
|
||||
|
||||
alphaData, err := s.ReadFile("nested/alpha.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("read alpha: %v", err)
|
||||
}
|
||||
if string(alphaData) != "alpha" {
|
||||
t.Fatalf("read alpha = %q, want %q", alphaData, "alpha")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+newFileSuffix), []byte("tmp"), 0o644); err != nil {
|
||||
t.Fatalf("create stale .new file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+oldFileSuffix), []byte("tmp"), 0o644); err != nil {
|
||||
t.Fatalf("create stale .old file: %v", err)
|
||||
}
|
||||
|
||||
files, err := s.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("list files: %v", err)
|
||||
}
|
||||
wantFiles := []string{
|
||||
"beta.txt",
|
||||
filepath.Join("nested", "alpha.txt"),
|
||||
}
|
||||
if !reflect.DeepEqual(files, wantFiles) {
|
||||
t.Fatalf("listed files mismatch\nwant: %#v\ngot: %#v", wantFiles, files)
|
||||
}
|
||||
|
||||
if err := s.DeleteFile("beta.txt"); err != nil {
|
||||
t.Fatalf("delete beta: %v", err)
|
||||
}
|
||||
if err := s.DeleteFile("beta.txt"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("delete missing beta error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
for _, path := range []string{"../escape.txt", "..\\escape.txt", ""} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
err := s.WriteFile(path, []byte("blocked"))
|
||||
if err == nil {
|
||||
t.Fatalf("write %q unexpectedly succeeded", path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWriteFirstAndOverwrite(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := filepath.Join("turns", "12.bin")
|
||||
|
||||
if err := s.WriteFile(target, []byte("first")); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
assertFileContent(t, s, target, "first")
|
||||
assertNoTempArtifacts(t, s, target)
|
||||
|
||||
if err := s.WriteFile(target, []byte("second")); err != nil {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
assertFileContent(t, s, target, "second")
|
||||
assertNoTempArtifacts(t, s, target)
|
||||
}
|
||||
|
||||
func TestAtomicWriteStaleTempCollision(t *testing.T) {
|
||||
t.Run("stale new file", func(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := "collision-new.txt"
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absTarget), os.ModePerm); err != nil {
|
||||
t.Fatalf("create parent dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absTarget+newFileSuffix, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatalf("write stale new file: %v", err)
|
||||
}
|
||||
|
||||
err = s.WriteFile(target, []byte("payload"))
|
||||
if err == nil || !strings.Contains(err.Error(), "new file already exists") {
|
||||
t.Fatalf("write error = %v, want stale new file error", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale old file", func(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := "collision-old.txt"
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absTarget, []byte("current"), 0o644); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absTarget+oldFileSuffix, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatalf("write stale old file: %v", err)
|
||||
}
|
||||
|
||||
err = s.WriteFile(target, []byte("payload"))
|
||||
if err == nil || !strings.Contains(err.Error(), "old file already exists") {
|
||||
t.Fatalf("write error = %v, want stale old file error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAtomicWriteRollbackOnRenameFailure(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := filepath.Join("rollback", "state.txt")
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
|
||||
if err := s.WriteFile(target, []byte("original")); err != nil {
|
||||
t.Fatalf("seed target file: %v", err)
|
||||
}
|
||||
|
||||
origRename := s.renameFileFn
|
||||
s.renameFileFn = func(oldPath, newPath string) error {
|
||||
if oldPath == absTarget+newFileSuffix && newPath == absTarget {
|
||||
return errors.New("forced rename failure")
|
||||
}
|
||||
return origRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
err = s.WriteFile(target, []byte("replacement"))
|
||||
if err == nil || !strings.Contains(err.Error(), "forced rename failure") {
|
||||
t.Fatalf("write error = %v, want forced rename failure", err)
|
||||
}
|
||||
|
||||
assertFileContent(t, s, target, "original")
|
||||
assertNoTempArtifacts(t, s, target)
|
||||
}
|
||||
|
||||
func TestSamePathOperationsSerialize(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
target := "shared.txt"
|
||||
absTarget, err := s.resolvePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
origWrite := s.writeFileFn
|
||||
var writes atomic.Int32
|
||||
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
||||
if path == absTarget+newFileSuffix && writes.Add(1) == 1 {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
return origWrite(path, data, perm)
|
||||
}
|
||||
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
firstDone <- s.WriteFile(target, []byte("one"))
|
||||
}()
|
||||
waitSignal(t, entered, "first write entered")
|
||||
|
||||
secondDone := make(chan error, 1)
|
||||
go func() {
|
||||
secondDone <- s.WriteFile(target, []byte("two"))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
t.Fatalf("second write finished before first released: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
if writes.Load() != 1 {
|
||||
t.Fatalf("same-path write reached file hook %d times before release, want 1", writes.Load())
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitError(t, firstDone); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
if err := waitError(t, secondDone); err != nil {
|
||||
t.Fatalf("second write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentPathOperationsDoNotBlockEachOther(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
blockedTarget := "blocked.txt"
|
||||
absTarget, err := s.resolvePath(blockedTarget)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve blocked target: %v", err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
origWrite := s.writeFileFn
|
||||
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
||||
if path == absTarget+newFileSuffix {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
return origWrite(path, data, perm)
|
||||
}
|
||||
|
||||
blockedDone := make(chan error, 1)
|
||||
go func() {
|
||||
blockedDone <- s.WriteFile(blockedTarget, []byte("blocked"))
|
||||
}()
|
||||
waitSignal(t, entered, "blocked write entered")
|
||||
|
||||
freeDone := make(chan error, 1)
|
||||
go func() {
|
||||
freeDone <- s.WriteFile("free.txt", []byte("free"))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-freeDone:
|
||||
if err != nil {
|
||||
t.Fatalf("free write: %v", err)
|
||||
}
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("write for a different path should not block")
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitError(t, blockedDone); err != nil {
|
||||
t.Fatalf("blocked write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStateIsNonBlockingAndCallbackBased(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
origWrite := s.writeFileFn
|
||||
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
||||
close(entered)
|
||||
<-release
|
||||
return origWrite(path, data, perm)
|
||||
}
|
||||
|
||||
callbacks := make(chan error, 2)
|
||||
s.SaveState(sampleState(), func(err error) {
|
||||
callbacks <- err
|
||||
})
|
||||
|
||||
waitSignal(t, entered, "async save entered")
|
||||
|
||||
select {
|
||||
case err := <-callbacks:
|
||||
t.Fatalf("callback fired before storage write completed: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitError(t, callbacks); err != nil {
|
||||
t.Fatalf("callback error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-callbacks:
|
||||
t.Fatalf("callback fired more than once: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStorage(t *testing.T) *fsStorage {
|
||||
t.Helper()
|
||||
|
||||
s, err := NewFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new test storage: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sampleState() client.State {
|
||||
return client.State{
|
||||
GameState: []client.GameState{
|
||||
{ID: client.GameID("game-1"), LastTurn: 12, ActiveTurn: 11},
|
||||
{ID: client.GameID("game-2"), LastTurn: 4, ActiveTurn: 4},
|
||||
},
|
||||
ActiveGameID: client.GameID("game-2"),
|
||||
}
|
||||
}
|
||||
|
||||
func sampleReport(turn uint, race string) report.Report {
|
||||
return report.Report{
|
||||
Turn: turn,
|
||||
Width: 160,
|
||||
Height: 90,
|
||||
PlanetCount: 8,
|
||||
Race: race,
|
||||
VoteFor: "assembly",
|
||||
}
|
||||
}
|
||||
|
||||
func sampleOrder() order.Order {
|
||||
return order.Order{
|
||||
UpdatedAt: 1700,
|
||||
Commands: []order.DecodableCommand{
|
||||
&order.CommandPlanetRename{
|
||||
CommandMeta: order.CommandMeta{
|
||||
CmdType: order.CommandTypePlanetRename,
|
||||
CmdID: "rename-planet",
|
||||
},
|
||||
Number: 2,
|
||||
Name: "Nova Prime",
|
||||
},
|
||||
&order.CommandRaceVote{
|
||||
CommandMeta: order.CommandMeta{
|
||||
CmdType: order.CommandTypeRaceVote,
|
||||
CmdID: "vote-race",
|
||||
},
|
||||
Acceptor: "ZENITH",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, s *fsStorage, path, want string) {
|
||||
t.Helper()
|
||||
|
||||
got, err := s.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %q: %v", path, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("content for %q = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoTempArtifacts(t *testing.T, s *fsStorage, path string) {
|
||||
t.Helper()
|
||||
|
||||
absPath, err := s.resolvePath(path)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve path %q: %v", path, err)
|
||||
}
|
||||
for _, tempPath := range []string{absPath + newFileSuffix, absPath + oldFileSuffix} {
|
||||
if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("temp artifact %q should not exist, stat err = %v", tempPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitSignal(t *testing.T, ch <-chan struct{}, name string) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatalf("timeout waiting for %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func waitError(t *testing.T, ch <-chan error) error {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case err := <-ch:
|
||||
return err
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("timeout waiting for error callback")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitResult[T any](t *testing.T, ch <-chan callbackResult[T]) callbackResult[T] {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
return result
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("timeout waiting for callback result")
|
||||
return callbackResult[T]{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SemVer stores a numeric semantic version as major, minor, patch, and build
|
||||
// components. Components that are not provided are represented as zero values.
|
||||
type SemVer struct {
|
||||
Major uint
|
||||
Minor uint
|
||||
Patch uint
|
||||
Build uint
|
||||
}
|
||||
|
||||
// NewSemver constructs a SemVer from v.
|
||||
//
|
||||
// NewSemver requires between one and four components ordered as major, minor,
|
||||
// patch, and build. Components that are not provided are set to zero.
|
||||
func NewSemver(v ...uint) (SemVer, error) {
|
||||
s := &SemVer{}
|
||||
switch len(v) {
|
||||
case 4:
|
||||
s.Build = v[3]
|
||||
fallthrough
|
||||
case 3:
|
||||
s.Patch = v[2]
|
||||
fallthrough
|
||||
case 2:
|
||||
s.Minor = v[1]
|
||||
fallthrough
|
||||
case 1:
|
||||
s.Major = v[0]
|
||||
default:
|
||||
return *s, fmt.Errorf("new semver: incorrect args count: %d", len(v))
|
||||
}
|
||||
return *s, nil
|
||||
}
|
||||
|
||||
// MustSemver returns the SemVer produced by NewSemver(v...).
|
||||
//
|
||||
// MustSemver panics if NewSemver returns an error.
|
||||
func MustSemver(v ...uint) SemVer {
|
||||
if v, err := NewSemver(v...); err != nil {
|
||||
panic(err)
|
||||
} else {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSemver parses input into a SemVer.
|
||||
//
|
||||
// ParseSemver accepts versions with one to four numeric components separated by
|
||||
// dots, for example "1", "1.2", "1.2.3", or "1.2.3.4". The input may also use
|
||||
// the optional "v" or "v." prefix. Missing minor, patch, and build components
|
||||
// are set to zero.
|
||||
func ParseSemver(input string) (SemVer, error) {
|
||||
source := input
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(input, "v."):
|
||||
input = strings.TrimPrefix(input, "v.")
|
||||
case strings.HasPrefix(input, "v"):
|
||||
input = strings.TrimPrefix(input, "v")
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
return SemVer{}, fmt.Errorf("parse semver %q: missing major version", source)
|
||||
}
|
||||
|
||||
parts := strings.Split(input, ".")
|
||||
if len(parts) > 4 {
|
||||
return SemVer{}, fmt.Errorf("parse semver %q: too many version parts: %d", source, len(parts))
|
||||
}
|
||||
|
||||
values := make([]uint, 0, len(parts))
|
||||
for idx, part := range parts {
|
||||
if part == "" {
|
||||
return SemVer{}, fmt.Errorf("parse semver %q: empty version part at position %d", source, idx+1)
|
||||
}
|
||||
|
||||
value, err := strconv.ParseUint(part, 10, 0)
|
||||
if err != nil {
|
||||
return SemVer{}, fmt.Errorf("parse semver %q: parse part %q at position %d: %w", source, part, idx+1, err)
|
||||
}
|
||||
|
||||
values = append(values, uint(value))
|
||||
}
|
||||
|
||||
version, err := NewSemver(values...)
|
||||
if err != nil {
|
||||
return SemVer{}, fmt.Errorf("parse semver %q: %w", source, err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// ParserSemver calls ParseSemver.
|
||||
//
|
||||
// Deprecated: use ParseSemver.
|
||||
func ParserSemver(input string) (SemVer, error) {
|
||||
return ParseSemver(input)
|
||||
}
|
||||
|
||||
// CompareSemver compares two semantic versions and returns:
|
||||
//
|
||||
// +1 if x is less than y,
|
||||
// 0 if x equals y,
|
||||
// -1 if x is greater than y.
|
||||
func CompareSemver(x, y SemVer) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(y.Major, x.Major),
|
||||
cmp.Compare(y.Minor, x.Minor),
|
||||
cmp.Compare(y.Patch, x.Patch),
|
||||
cmp.Compare(y.Build, x.Build),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"galaxy/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSemver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input []uint
|
||||
want util.SemVer
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "major only",
|
||||
input: []uint{1},
|
||||
want: util.SemVer{Major: 1},
|
||||
},
|
||||
{
|
||||
name: "major and minor",
|
||||
input: []uint{1, 2},
|
||||
want: util.SemVer{Major: 1, Minor: 2},
|
||||
},
|
||||
{
|
||||
name: "major minor and patch",
|
||||
input: []uint{1, 2, 3},
|
||||
want: util.SemVer{Major: 1, Minor: 2, Patch: 3},
|
||||
},
|
||||
{
|
||||
name: "all components",
|
||||
input: []uint{1, 2, 3, 4},
|
||||
want: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4},
|
||||
},
|
||||
{
|
||||
name: "missing major",
|
||||
input: nil,
|
||||
wantErr: "incorrect args count: 0",
|
||||
},
|
||||
{
|
||||
name: "too many components",
|
||||
input: []uint{1, 2, 3, 4, 5},
|
||||
wantErr: "incorrect args count: 5",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := util.NewSemver(tt.input...)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
assert.Equal(t, util.SemVer{}, got)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustSemver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("returns version", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4}, util.MustSemver(1, 2, 3, 4))
|
||||
})
|
||||
|
||||
t.Run("panics on invalid input", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
recovered = recover()
|
||||
}()
|
||||
|
||||
util.MustSemver()
|
||||
}()
|
||||
|
||||
require.NotNil(t, recovered)
|
||||
|
||||
err, ok := recovered.(error)
|
||||
require.True(t, ok)
|
||||
assert.EqualError(t, err, "new semver: incorrect args count: 0")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSemver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want util.SemVer
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "major only",
|
||||
input: "1",
|
||||
want: util.SemVer{Major: 1},
|
||||
},
|
||||
{
|
||||
name: "major and minor",
|
||||
input: "1.2",
|
||||
want: util.SemVer{Major: 1, Minor: 2},
|
||||
},
|
||||
{
|
||||
name: "major minor and patch",
|
||||
input: "1.2.3",
|
||||
want: util.SemVer{Major: 1, Minor: 2, Patch: 3},
|
||||
},
|
||||
{
|
||||
name: "all components",
|
||||
input: "1.2.3.4",
|
||||
want: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4},
|
||||
},
|
||||
{
|
||||
name: "v prefix",
|
||||
input: "v2.3.4.5",
|
||||
want: util.SemVer{Major: 2, Minor: 3, Patch: 4, Build: 5},
|
||||
},
|
||||
{
|
||||
name: "v dot prefix",
|
||||
input: "v.6.7.8.9",
|
||||
want: util.SemVer{Major: 6, Minor: 7, Patch: 8, Build: 9},
|
||||
},
|
||||
{
|
||||
name: "leading zeros",
|
||||
input: "v.01.002.0003.0004",
|
||||
want: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantErr: "missing major version",
|
||||
},
|
||||
{
|
||||
name: "prefix without version",
|
||||
input: "v",
|
||||
wantErr: "missing major version",
|
||||
},
|
||||
{
|
||||
name: "prefix with dot without version",
|
||||
input: "v.",
|
||||
wantErr: "missing major version",
|
||||
},
|
||||
{
|
||||
name: "leading dot",
|
||||
input: ".1",
|
||||
wantErr: "empty version part at position 1",
|
||||
},
|
||||
{
|
||||
name: "trailing dot",
|
||||
input: "1.",
|
||||
wantErr: "empty version part at position 2",
|
||||
},
|
||||
{
|
||||
name: "empty middle part",
|
||||
input: "1..2",
|
||||
wantErr: "empty version part at position 2",
|
||||
},
|
||||
{
|
||||
name: "too many parts",
|
||||
input: "1.2.3.4.5",
|
||||
wantErr: "too many version parts: 5",
|
||||
},
|
||||
{
|
||||
name: "non numeric part",
|
||||
input: "1.2.beta",
|
||||
wantErr: `parse part "beta"`,
|
||||
},
|
||||
{
|
||||
name: "negative part",
|
||||
input: "1.-2",
|
||||
wantErr: `parse part "-2"`,
|
||||
},
|
||||
{
|
||||
name: "spaces are not accepted",
|
||||
input: " 1.2 ",
|
||||
wantErr: `parse part " 1"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := util.ParseSemver(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
assert.Equal(t, util.SemVer{}, got)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserSemver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := util.ParserSemver("v1.2.3.4")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4}, got)
|
||||
}
|
||||
|
||||
func TestCompareSemver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
x util.SemVer
|
||||
y util.SemVer
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "equal versions",
|
||||
x: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4},
|
||||
y: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "x less by major",
|
||||
x: util.SemVer{Major: 1, Minor: 9, Patch: 9, Build: 9},
|
||||
y: util.SemVer{Major: 2},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "x greater by major",
|
||||
x: util.SemVer{Major: 2},
|
||||
y: util.SemVer{Major: 1, Minor: 9, Patch: 9, Build: 9},
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "x less by minor",
|
||||
x: util.SemVer{Major: 1, Minor: 1, Patch: 9, Build: 9},
|
||||
y: util.SemVer{Major: 1, Minor: 2},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "x less by patch",
|
||||
x: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 9},
|
||||
y: util.SemVer{Major: 1, Minor: 2, Patch: 4},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "x less by build",
|
||||
x: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 4},
|
||||
y: util.SemVer{Major: 1, Minor: 2, Patch: 3, Build: 5},
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tt.want, util.CompareSemver(tt.x, tt.y))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user