package internalhttp import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "runtime" "strings" "sync" "testing" "time" "galaxy/gamemaster/internal/api/internalhttp/handlers" "galaxy/gamemaster/internal/domain/engineversion" "galaxy/gamemaster/internal/domain/operation" domainruntime "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/service/adminbanish" "galaxy/gamemaster/internal/service/adminforce" "galaxy/gamemaster/internal/service/adminpatch" "galaxy/gamemaster/internal/service/adminstop" "galaxy/gamemaster/internal/service/commandexecute" engineversionsvc "galaxy/gamemaster/internal/service/engineversion" "galaxy/gamemaster/internal/service/livenessreply" "galaxy/gamemaster/internal/service/orderput" "galaxy/gamemaster/internal/service/registerruntime" "galaxy/gamemaster/internal/service/reportget" "galaxy/gamemaster/internal/service/turngeneration" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/getkin/kin-openapi/routers" "github.com/getkin/kin-openapi/routers/legacy" "github.com/stretchr/testify/require" ) // TestInternalRESTConformance loads the OpenAPI specification, drives // every internal REST operation against the live listener backed by // stub services, and validates each request and response body // against the spec via `openapi3filter.ValidateRequest` and // `openapi3filter.ValidateResponse`. Failure-path response shapes // are intentionally out of scope here; per-handler tests under // `handlers/_test.go` cover the failure branches. func TestInternalRESTConformance(t *testing.T) { t.Parallel() doc := loadConformanceSpec(t) router, err := legacy.NewRouter(doc) require.NoError(t, err) deps := newConformanceDeps() server, err := NewServer(newConformanceConfig(), Dependencies{ Logger: nil, Telemetry: nil, Readiness: nil, RuntimeRecords: deps.runtimeRecords, RegisterRuntime: deps.registerRuntime, ForceNextTurn: deps.forceNextTurn, StopRuntime: deps.stopRuntime, PatchRuntime: deps.patchRuntime, BanishRace: deps.banishRace, InvalidateMemberships: deps.membership, GameLiveness: deps.liveness, EngineVersions: deps.engineVersions, CommandExecute: deps.commandExecute, PutOrders: deps.putOrders, GetReport: deps.getReport, }) require.NoError(t, err) cases := []conformanceCase{ {name: "internalHealthz", method: http.MethodGet, path: "/healthz"}, {name: "internalReadyz", method: http.MethodGet, path: "/readyz"}, { name: "internalRegisterRuntime", method: http.MethodPost, path: "/api/v1/internal/games/" + conformanceGameID + "/register-runtime", contentType: "application/json", body: `{ "engine_endpoint": "http://galaxy-game-` + conformanceGameID + `:8080", "members": [{"user_id": "user-1", "race_name": "Aelinari"}], "target_engine_version": "1.2.3", "turn_schedule": "0 18 * * *" }`, }, { name: "internalBanishRace", method: http.MethodPost, path: "/api/v1/internal/games/" + conformanceGameID + "/race/Aelinari/banish", expectedStatus: http.StatusNoContent, }, { name: "internalInvalidateMemberships", method: http.MethodPost, path: "/api/v1/internal/games/" + conformanceGameID + "/memberships/invalidate", expectedStatus: http.StatusNoContent, }, { name: "internalGameLiveness", method: http.MethodGet, path: "/api/v1/internal/games/" + conformanceGameID + "/liveness", }, {name: "internalListRuntimes", method: http.MethodGet, path: "/api/v1/internal/runtimes"}, { name: "internalGetRuntime", method: http.MethodGet, path: "/api/v1/internal/runtimes/" + conformanceGameID, }, { name: "internalForceNextTurn", method: http.MethodPost, path: "/api/v1/internal/runtimes/" + conformanceGameID + "/force-next-turn", }, { name: "internalStopRuntime", method: http.MethodPost, path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop", contentType: "application/json", body: `{"reason":"admin_request"}`, }, { name: "internalPatchRuntime", method: http.MethodPost, path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch", contentType: "application/json", body: `{"version":"1.2.4"}`, }, {name: "internalListEngineVersions", method: http.MethodGet, path: "/api/v1/internal/engine-versions"}, { name: "internalCreateEngineVersion", method: http.MethodPost, path: "/api/v1/internal/engine-versions", contentType: "application/json", body: `{"version":"1.2.5","image_ref":"galaxy/game:1.2.5"}`, expectedStatus: http.StatusCreated, }, { name: "internalGetEngineVersion", method: http.MethodGet, path: "/api/v1/internal/engine-versions/1.2.3", }, { name: "internalUpdateEngineVersion", method: http.MethodPatch, path: "/api/v1/internal/engine-versions/1.2.3", contentType: "application/json", body: `{"image_ref":"galaxy/game:1.2.3-patch"}`, }, { name: "internalDeprecateEngineVersion", method: http.MethodDelete, path: "/api/v1/internal/engine-versions/1.2.3", expectedStatus: http.StatusNoContent, }, { name: "internalResolveEngineVersionImageRef", method: http.MethodGet, path: "/api/v1/internal/engine-versions/1.2.3/image-ref", }, { name: "internalExecuteCommands", method: http.MethodPost, path: "/api/v1/internal/games/" + conformanceGameID + "/commands", contentType: "application/json", body: `{"commands":[{"name":"build","args":{}}]}`, extraHeaders: map[string]string{userIDHeader: conformanceUserID}, }, { name: "internalPutOrders", method: http.MethodPost, path: "/api/v1/internal/games/" + conformanceGameID + "/orders", contentType: "application/json", body: `{"commands":[{"name":"move","args":{}}]}`, extraHeaders: map[string]string{userIDHeader: conformanceUserID}, }, { name: "internalGetReport", method: http.MethodGet, path: "/api/v1/internal/games/" + conformanceGameID + "/reports/0", extraHeaders: map[string]string{userIDHeader: conformanceUserID}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() runConformanceCase(t, server.handler, router, tc) }) } } const ( conformanceGameID = "game-conformance" conformanceUserID = "user-conformance" conformanceServerURL = "http://localhost:8097" userIDHeader = "X-User-ID" ) type conformanceCase struct { name string method string path string contentType string body string expectedStatus int extraHeaders map[string]string } func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) { t.Helper() expectedStatus := tc.expectedStatus if expectedStatus == 0 { expectedStatus = http.StatusOK } var bodyReader io.Reader if tc.body != "" { bodyReader = strings.NewReader(tc.body) } request := httptest.NewRequest(tc.method, tc.path, bodyReader) if tc.contentType != "" { request.Header.Set("Content-Type", tc.contentType) } request.Header.Set("X-Galaxy-Caller", "admin") for key, value := range tc.extraHeaders { request.Header.Set(key, value) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) require.Equalf(t, expectedStatus, recorder.Code, "operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String()) validationURL := conformanceServerURL + tc.path validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body)) if tc.contentType != "" { validationRequest.Header.Set("Content-Type", tc.contentType) } validationRequest.Header.Set("X-Galaxy-Caller", "admin") for key, value := range tc.extraHeaders { validationRequest.Header.Set(key, value) } route, pathParams, err := router.FindRoute(validationRequest) require.NoError(t, err) requestInput := &openapi3filter.RequestValidationInput{ Request: validationRequest, PathParams: pathParams, Route: route, Options: &openapi3filter.Options{ IncludeResponseStatus: true, }, } require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput)) responseInput := &openapi3filter.ResponseValidationInput{ RequestValidationInput: requestInput, Status: recorder.Code, Header: recorder.Header(), Options: &openapi3filter.Options{ IncludeResponseStatus: true, }, } responseInput.SetBodyBytes(recorder.Body.Bytes()) require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput)) } func loadConformanceSpec(t *testing.T) *openapi3.T { t.Helper() _, thisFile, _, ok := runtime.Caller(0) require.True(t, ok) specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml") loader := openapi3.NewLoader() doc, err := loader.LoadFromFile(specPath) require.NoError(t, err) require.NoError(t, doc.Validate(context.Background())) return doc } func bodyReaderFor(raw string) io.Reader { if raw == "" { return http.NoBody } return bytes.NewBufferString(raw) } func newConformanceConfig() Config { return Config{ Addr: ":0", ReadHeaderTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, IdleTimeout: time.Second, } } // conformanceDeps groups the stub collaborators handed to the listener. type conformanceDeps struct { runtimeRecords *conformanceRuntimeRecords registerRuntime *conformanceRegister forceNextTurn *conformanceForce stopRuntime *conformanceStop patchRuntime *conformancePatch banishRace *conformanceBanish membership *conformanceMembership liveness *conformanceLiveness engineVersions *conformanceEngineVersions commandExecute *conformanceCommands putOrders *conformanceOrders getReport *conformanceReport } func newConformanceDeps() *conformanceDeps { return &conformanceDeps{ runtimeRecords: newConformanceRuntimeRecords(), registerRuntime: &conformanceRegister{}, forceNextTurn: &conformanceForce{}, stopRuntime: &conformanceStop{}, patchRuntime: &conformancePatch{}, banishRace: &conformanceBanish{}, membership: &conformanceMembership{}, liveness: &conformanceLiveness{}, engineVersions: newConformanceEngineVersions(), commandExecute: &conformanceCommands{}, putOrders: &conformanceOrders{}, getReport: &conformanceReport{}, } } // conformanceRecord builds a canonical running runtime record used // by every stub service. func conformanceRuntimeRecord() domainruntime.RuntimeRecord { moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) next := moment.Add(time.Minute) started := moment return domainruntime.RuntimeRecord{ GameID: conformanceGameID, Status: domainruntime.StatusRunning, EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080", CurrentImageRef: "galaxy/game:1.2.3", CurrentEngineVersion: "1.2.3", TurnSchedule: "0 18 * * *", CurrentTurn: 0, NextGenerationAt: &next, SkipNextTick: false, EngineHealth: "healthy", CreatedAt: moment, UpdatedAt: moment, StartedAt: &started, } } func conformanceEngineVersionRecord(version string) engineversion.EngineVersion { moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) return engineversion.EngineVersion{ Version: version, ImageRef: "galaxy/game:" + version, Options: nil, Status: engineversion.StatusActive, CreatedAt: moment, UpdatedAt: moment, } } // conformanceRuntimeRecords is an in-memory store seeded with the // canonical record so the get/list endpoints have something to return. type conformanceRuntimeRecords struct { mu sync.Mutex stored map[string]domainruntime.RuntimeRecord } func newConformanceRuntimeRecords() *conformanceRuntimeRecords { return &conformanceRuntimeRecords{ stored: map[string]domainruntime.RuntimeRecord{ conformanceGameID: conformanceRuntimeRecord(), }, } } func (s *conformanceRuntimeRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() record, ok := s.stored[gameID] if !ok { return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound } return record, nil } func (s *conformanceRuntimeRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() out := make([]domainruntime.RuntimeRecord, 0, len(s.stored)) for _, record := range s.stored { out = append(out, record) } return out, nil } func (s *conformanceRuntimeRecords) ListByStatus(_ context.Context, status domainruntime.Status) ([]domainruntime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() out := make([]domainruntime.RuntimeRecord, 0, len(s.stored)) for _, record := range s.stored { if record.Status == status { out = append(out, record) } } return out, nil } type conformanceRegister struct{} func (s *conformanceRegister) Handle(_ context.Context, _ registerruntime.Input) (registerruntime.Result, error) { return registerruntime.Result{ Record: conformanceRuntimeRecord(), Outcome: operation.OutcomeSuccess, }, nil } type conformanceForce struct{} func (s *conformanceForce) Handle(_ context.Context, _ adminforce.Input) (adminforce.Result, error) { return adminforce.Result{ TurnGeneration: turngeneration.Result{Record: conformanceRuntimeRecord()}, SkipScheduled: true, Outcome: operation.OutcomeSuccess, }, nil } type conformanceStop struct{} func (s *conformanceStop) Handle(_ context.Context, _ adminstop.Input) (adminstop.Result, error) { rec := conformanceRuntimeRecord() rec.Status = domainruntime.StatusStopped stopped := rec.UpdatedAt.Add(time.Second) rec.StoppedAt = &stopped rec.UpdatedAt = stopped return adminstop.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil } type conformancePatch struct{} func (s *conformancePatch) Handle(_ context.Context, in adminpatch.Input) (adminpatch.Result, error) { rec := conformanceRuntimeRecord() if in.Version != "" { rec.CurrentImageRef = "galaxy/game:" + in.Version rec.CurrentEngineVersion = in.Version } return adminpatch.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil } type conformanceBanish struct{} func (s *conformanceBanish) Handle(_ context.Context, _ adminbanish.Input) (adminbanish.Result, error) { return adminbanish.Result{Outcome: operation.OutcomeSuccess}, nil } type conformanceMembership struct{} func (m *conformanceMembership) Invalidate(string) {} type conformanceLiveness struct{} func (s *conformanceLiveness) Handle(_ context.Context, _ livenessreply.Input) (livenessreply.Result, error) { return livenessreply.Result{ Ready: true, Status: domainruntime.StatusRunning, }, nil } type conformanceEngineVersions struct { mu sync.Mutex versions map[string]engineversion.EngineVersion } func newConformanceEngineVersions() *conformanceEngineVersions { return &conformanceEngineVersions{ versions: map[string]engineversion.EngineVersion{ "1.2.3": conformanceEngineVersionRecord("1.2.3"), }, } } func (s *conformanceEngineVersions) List(_ context.Context, _ *engineversion.Status) ([]engineversion.EngineVersion, error) { s.mu.Lock() defer s.mu.Unlock() out := make([]engineversion.EngineVersion, 0, len(s.versions)) for _, version := range s.versions { out = append(out, version) } return out, nil } func (s *conformanceEngineVersions) Get(_ context.Context, version string) (engineversion.EngineVersion, error) { s.mu.Lock() defer s.mu.Unlock() v, ok := s.versions[version] if !ok { return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound } return v, nil } func (s *conformanceEngineVersions) ResolveImageRef(_ context.Context, version string) (string, error) { s.mu.Lock() defer s.mu.Unlock() v, ok := s.versions[version] if !ok { return "", engineversionsvc.ErrNotFound } return v.ImageRef, nil } func (s *conformanceEngineVersions) Create(_ context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error) { rec := engineversion.EngineVersion{ Version: in.Version, ImageRef: in.ImageRef, Options: in.Options, Status: engineversion.StatusActive, CreatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC), } s.mu.Lock() s.versions[in.Version] = rec s.mu.Unlock() return rec, nil } func (s *conformanceEngineVersions) Update(_ context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error) { s.mu.Lock() defer s.mu.Unlock() rec, ok := s.versions[in.Version] if !ok { return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound } if in.ImageRef != nil { rec.ImageRef = *in.ImageRef } if in.Status != nil { rec.Status = *in.Status } rec.UpdatedAt = time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC) s.versions[in.Version] = rec return rec, nil } func (s *conformanceEngineVersions) Deprecate(_ context.Context, in engineversionsvc.DeprecateInput) error { s.mu.Lock() defer s.mu.Unlock() rec, ok := s.versions[in.Version] if !ok { return engineversionsvc.ErrNotFound } rec.Status = engineversion.StatusDeprecated rec.UpdatedAt = time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC) s.versions[in.Version] = rec return nil } type conformanceCommands struct{} func (s *conformanceCommands) Handle(_ context.Context, _ commandexecute.Input) (commandexecute.Result, error) { return commandexecute.Result{ Outcome: operation.OutcomeSuccess, RawResponse: json.RawMessage(`{"results":[]}`), }, nil } type conformanceOrders struct{} func (s *conformanceOrders) Handle(_ context.Context, _ orderput.Input) (orderput.Result, error) { return orderput.Result{ Outcome: operation.OutcomeSuccess, RawResponse: json.RawMessage(`{"results":[]}`), }, nil } type conformanceReport struct{} func (s *conformanceReport) Handle(_ context.Context, _ reportget.Input) (reportget.Result, error) { return reportget.Result{ Outcome: operation.OutcomeSuccess, RawResponse: json.RawMessage(`{"player":"Aelinari","turn":0}`), }, nil } // Compile-time guards that the stubs satisfy the handler-level // service interfaces accepted by the listener. var ( _ handlers.RegisterRuntimeService = (*conformanceRegister)(nil) _ handlers.ForceNextTurnService = (*conformanceForce)(nil) _ handlers.StopRuntimeService = (*conformanceStop)(nil) _ handlers.PatchRuntimeService = (*conformancePatch)(nil) _ handlers.BanishRaceService = (*conformanceBanish)(nil) _ handlers.MembershipInvalidator = (*conformanceMembership)(nil) _ handlers.LivenessService = (*conformanceLiveness)(nil) _ handlers.EngineVersionService = (*conformanceEngineVersions)(nil) _ handlers.CommandExecuteService = (*conformanceCommands)(nil) _ handlers.OrderPutService = (*conformanceOrders)(nil) _ handlers.ReportGetService = (*conformanceReport)(nil) _ handlers.RuntimeRecordsReader = (*conformanceRuntimeRecords)(nil) )