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//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)