feat: runtime manager
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user