Files
galaxy-game/rtmanager/internal/api/internalhttp/handlers/common_test.go
T
2026-04-28 20:39:18 +02:00

198 lines
5.9 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"galaxy/rtmanager/internal/domain/runtime"
"galaxy/rtmanager/internal/ports"
"github.com/stretchr/testify/require"
)
// fixedClock is the wall-clock used to build canonical sample records
// across the handler tests. UTC Sunday 1pm 2026-04-26 is far enough in
// the future to be obvious in test output.
var fixedClock = time.Date(2026, 4, 26, 13, 0, 0, 0, time.UTC)
// sampleRunningRecord returns a canonical running record used by every
// happy-path test in this package.
func sampleRunningRecord(t *testing.T) runtime.RuntimeRecord {
t.Helper()
started := fixedClock
return runtime.RuntimeRecord{
GameID: "game-test",
Status: runtime.StatusRunning,
CurrentContainerID: "container-test",
CurrentImageRef: "galaxy/game:v1.2.3",
EngineEndpoint: "http://galaxy-game-game-test:8080",
StatePath: "/var/lib/galaxy/game-test",
DockerNetwork: "galaxy-engine",
StartedAt: &started,
LastOpAt: fixedClock,
CreatedAt: fixedClock,
}
}
// sampleStoppedRecord returns a canonical stopped record useful for
// cleanup-handler and list-handler tests.
func sampleStoppedRecord(t *testing.T) runtime.RuntimeRecord {
t.Helper()
started := fixedClock
stopped := fixedClock.Add(time.Minute)
return runtime.RuntimeRecord{
GameID: "game-stopped",
Status: runtime.StatusStopped,
CurrentContainerID: "container-stopped",
CurrentImageRef: "galaxy/game:v1.2.3",
EngineEndpoint: "http://galaxy-game-game-stopped:8080",
StatePath: "/var/lib/galaxy/game-stopped",
DockerNetwork: "galaxy-engine",
StartedAt: &started,
StoppedAt: &stopped,
LastOpAt: stopped,
CreatedAt: fixedClock,
}
}
// drive routes one request through a full mux configured by Register.
// It returns the captured ResponseRecorder so tests can assert on
// status, headers, and body.
func drive(t *testing.T, deps Dependencies, method, path string, headers http.Header, body io.Reader) *httptest.ResponseRecorder {
t.Helper()
mux := http.NewServeMux()
Register(mux, deps)
request := httptest.NewRequest(method, path, body)
for key, values := range headers {
for _, value := range values {
request.Header.Add(key, value)
}
}
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, request)
return recorder
}
// decodeRecordResponse asserts that the response carried a 200 with
// the canonical content type and decodes the record body.
func decodeRecordResponse(t *testing.T, rec *httptest.ResponseRecorder) runtimeRecordResponse {
t.Helper()
require.Equalf(t, http.StatusOK, rec.Code, "expected 200, got body: %s", rec.Body.String())
require.Equal(t, JSONContentType, rec.Header().Get("Content-Type"))
var resp runtimeRecordResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
return resp
}
// decodeErrorBody asserts the canonical error envelope and decodes it.
func decodeErrorBody(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int) errorBody {
t.Helper()
require.Equalf(t, wantStatus, rec.Code, "expected %d, got body: %s", wantStatus, rec.Body.String())
require.Equal(t, JSONContentType, rec.Header().Get("Content-Type"))
var resp errorResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
return resp.Error
}
// fakeRuntimeRecords is an in-memory ports.RuntimeRecordStore used by
// list / get tests. It is intentionally minimal — services use their
// own fakes in `internal/service/<op>/service_test.go` and do not
// share this helper.
type fakeRuntimeRecords struct {
mu sync.Mutex
stored map[string]runtime.RuntimeRecord
listErr error
getErr error
}
func newFakeRuntimeRecords() *fakeRuntimeRecords {
return &fakeRuntimeRecords{stored: map[string]runtime.RuntimeRecord{}}
}
func (s *fakeRuntimeRecords) put(record runtime.RuntimeRecord) {
s.mu.Lock()
defer s.mu.Unlock()
s.stored[record.GameID] = record
}
func (s *fakeRuntimeRecords) Get(_ context.Context, gameID string) (runtime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.getErr != nil {
return runtime.RuntimeRecord{}, s.getErr
}
record, ok := s.stored[gameID]
if !ok {
return runtime.RuntimeRecord{}, runtime.ErrNotFound
}
return record, nil
}
func (s *fakeRuntimeRecords) Upsert(_ context.Context, _ runtime.RuntimeRecord) error {
return errors.New("not used in handler tests")
}
func (s *fakeRuntimeRecords) UpdateStatus(_ context.Context, _ ports.UpdateStatusInput) error {
return errors.New("not used in handler tests")
}
func (s *fakeRuntimeRecords) ListByStatus(_ context.Context, _ runtime.Status) ([]runtime.RuntimeRecord, error) {
return nil, errors.New("not used in handler tests")
}
func (s *fakeRuntimeRecords) List(_ context.Context) ([]runtime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.listErr != nil {
return nil, s.listErr
}
if len(s.stored) == 0 {
return nil, nil
}
records := make([]runtime.RuntimeRecord, 0, len(s.stored))
for _, record := range s.stored {
records = append(records, record)
}
return records, nil
}
// jsonHeaders returns the default headers used by tests that send a
// JSON body.
func jsonHeaders() http.Header {
h := http.Header{}
h.Set("Content-Type", "application/json")
return h
}
// withCaller adds the X-Galaxy-Caller header to h and returns h. The
// helper exists to keep test cases readable when the header is the
// only difference between two table rows.
func withCaller(h http.Header, value string) http.Header {
if h == nil {
h = http.Header{}
}
h.Set(callerHeader, value)
return h
}
// strReader builds an io.Reader from raw JSON.
func strReader(raw string) io.Reader {
return strings.NewReader(raw)
}
// Compile-time assertions that the in-memory fake satisfies the port.
var _ ports.RuntimeRecordStore = (*fakeRuntimeRecords)(nil)