Files
galaxy-game/backend/internal/server/contract_test.go
T
Ilia Denisov 362f92e520
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m36s
diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.

EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:02:46 +02:00

486 lines
13 KiB
Go

package server
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/gorillamux"
)
// contractStubAdminPassword is the password the contract test injects
// into the stub Basic Auth verifier wired by NewRouter. The value
// never leaves the test binary; production wires the verifier from
// the Postgres-backed admin.Service.
const contractStubAdminPassword = "contract-test-secret"
// stubUserID is the deterministic UUID injected into `X-User-ID` for the
// authenticated user surface.
const stubUserID = "00000000-0000-0000-0000-0000000000a1"
// pathParamStubs lists the deterministic substitutions used to fill the path
// templates declared in `openapi.yaml`. Every parameter that appears in a path
// must have an entry here; the test fails loudly if a new parameter is added
// to the spec without updating this map.
var pathParamStubs = map[string]string{
"game_id": "00000000-0000-0000-0000-000000000001",
"application_id": "00000000-0000-0000-0000-000000000002",
"invite_id": "00000000-0000-0000-0000-000000000003",
"membership_id": "00000000-0000-0000-0000-000000000004",
"notification_id": "00000000-0000-0000-0000-000000000005",
"delivery_id": "00000000-0000-0000-0000-000000000006",
"user_id": "00000000-0000-0000-0000-000000000007",
"device_session_id": "00000000-0000-0000-0000-000000000008",
"battle_id": "00000000-0000-0000-0000-000000000009",
"message_id": "00000000-0000-0000-0000-00000000000a",
"id": "1.2.3",
"username": "alice",
"turn": "42",
}
// queryParamStubs lists the deterministic substitutions used to fill
// query-string parameters declared in `openapi.yaml`. Every required
// query parameter must have an entry here; optional ones can stay
// blank (the contract test omits them when no stub is registered).
var queryParamStubs = map[string]string{
"turn": "42",
}
// requestBodyStubs lists the JSON request bodies the contract test sends for
// each operationId. Operations missing from the map default to an empty
// object `{}`, which is a valid placeholder thanks to `additionalProperties:
// true` in the matching schemas. Operations that require specific fields
// listed in `openapi.yaml` get a hand-crafted body here.
var requestBodyStubs = map[string]map[string]any{
"publicAuthSendEmailCode": {
"email": "pilot@example.test",
},
"publicAuthConfirmEmailCode": {
"challenge_id": "challenge-123",
"code": "654321",
"client_public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"time_zone": "Europe/Kaliningrad",
},
"adminAdminAccountsCreate": {
"username": "carol",
"password": "carol-secret",
},
"adminAdminAccountsResetPassword": {
"password": "carol-new-secret",
},
"adminEngineVersionsCreate": {
"version": "1.2.3",
"image_ref": "registry.test/galaxy/engine:1.2.3",
},
"adminRuntimesPatch": {
"target_version": "1.2.4",
},
"userLobbyRaceNamesRegister": {
"name": "AndromedaConfederacy",
},
"adminUsersAddSanction": {
"sanction_code": "permanent_block",
"scope": "platform",
"reason_code": "tos_violation",
"actor": map[string]any{
"type": "admin",
"id": "operator",
},
},
"adminUsersAddLimit": {
"limit_code": "max_active_games",
"value": 3,
"reason_code": "manual_review",
"actor": map[string]any{
"type": "admin",
"id": "operator",
},
},
"adminUsersAddEntitlement": {
"tier": "monthly",
"source": "admin",
"actor": map[string]any{
"type": "admin",
"id": "operator",
},
},
"userLobbyGamesCreate": {
"game_name": "Contract Test Game",
"visibility": "private",
"min_players": 2,
"max_players": 8,
"start_gap_hours": 24,
"start_gap_players": 2,
"enrollment_ends_at": "2099-01-02T03:04:05Z",
"turn_schedule": "0 0 * * *",
"target_engine_version": "1.0.0",
},
"adminGamesCreate": {
"game_name": "Contract Test Public Game",
"min_players": 4,
"max_players": 12,
"start_gap_hours": 12,
"start_gap_players": 4,
"enrollment_ends_at": "2099-01-02T03:04:05Z",
"turn_schedule": "0 6 * * *",
"target_engine_version": "1.0.0",
},
"userLobbyApplicationsSubmit": {
"race_name": "ContractTestRace",
},
"userLobbyInvitesIssue": {
"invited_user_id": pathParamStubs["user_id"],
"race_name": "ContractTestRace",
},
"adminGamesBanMember": {
"user_id": pathParamStubs["user_id"],
"reason": "ToS violation",
},
"userMailSendPersonal": {
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test subject",
"body": "Contract test body",
},
"userMailSendAdmin": {
"target": "user",
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test admin subject",
"body": "Contract test admin body",
},
"adminDiplomailSend": {
"target": "user",
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test admin subject",
"body": "Contract test admin body",
},
"userMailSendBroadcast": {
"subject": "Contract test paid broadcast",
"body": "Contract test paid broadcast body",
},
"adminDiplomailBroadcast": {
"scope": "all_running",
"subject": "Contract test multi-game broadcast",
"body": "Contract test multi-game broadcast body",
},
"adminDiplomailCleanup": {
"older_than_years": 1,
},
}
// TestOpenAPIContract is the top-level OpenAPI contract test. It
// validates that:
//
// 1. backend/openapi.yaml is well-formed;
// 2. every documented operation maps to a registered gin handler;
// 3. each handler returns a representable HTTP response that satisfies
// the declared response schema (any defensive `501 not_implemented`
// placeholder also conforms).
func TestOpenAPIContract(t *testing.T) {
t.Parallel()
doc, specPath := loadOpenAPISpec(t)
spec := mustValidateSpec(t, doc, specPath)
router := mustNewGorillamuxRouter(t, doc)
engine := mustBuildEngine(t)
expectedOps := collectOperations(t, doc)
if len(expectedOps) == 0 {
t.Fatalf("openapi.yaml declares no operations")
}
_ = spec
processed := 0
for _, op := range expectedOps {
t.Run(op.method+" "+op.path, func(t *testing.T) {
t.Parallel()
runContractCase(t, router, engine, op)
})
processed++
}
t.Logf("contract test exercised %d operations from %s", processed, specPath)
}
type contractOperation struct {
method string
path string
operationID string
op *openapi3.Operation
}
func collectOperations(t *testing.T, doc *openapi3.T) []contractOperation {
t.Helper()
var ops []contractOperation
for path, item := range doc.Paths.Map() {
if item == nil {
continue
}
for method, op := range item.Operations() {
if op == nil {
continue
}
ops = append(ops, contractOperation{
method: method,
path: path,
operationID: op.OperationID,
op: op,
})
}
}
sort.Slice(ops, func(i, j int) bool {
if ops[i].path == ops[j].path {
return ops[i].method < ops[j].method
}
return ops[i].path < ops[j].path
})
return ops
}
func loadOpenAPISpec(t *testing.T) (*openapi3.T, string) {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatalf("runtime.Caller(0) failed")
}
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "openapi.yaml")
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = false
doc, err := loader.LoadFromFile(specPath)
if err != nil {
t.Fatalf("load openapi spec %s: %v", specPath, err)
}
return doc, specPath
}
func mustValidateSpec(t *testing.T, doc *openapi3.T, specPath string) *openapi3.T {
t.Helper()
if err := doc.Validate(context.Background()); err != nil {
t.Fatalf("openapi spec %s did not validate: %v", specPath, err)
}
if doc.Info == nil || doc.Info.Version != "v1" {
t.Fatalf("openapi spec must declare info.version v1, got %+v", doc.Info)
}
return doc
}
func mustNewGorillamuxRouter(t *testing.T, doc *openapi3.T) routers.Router {
t.Helper()
router, err := gorillamux.NewRouter(doc)
if err != nil {
t.Fatalf("build gorillamux router: %v", err)
}
return router
}
func mustBuildEngine(t *testing.T) http.Handler {
t.Helper()
verifier := basicauth.NewStaticVerifier(contractStubAdminPassword)
handler, err := NewRouter(RouterDependencies{
AdminVerifier: verifier,
})
if err != nil {
t.Fatalf("build router: %v", err)
}
return handler
}
func runContractCase(t *testing.T, router routers.Router, engine http.Handler, c contractOperation) {
t.Helper()
if c.operationID == "" {
t.Fatalf("operation %s %s has no operationId", c.method, c.path)
}
req := buildRequest(t, c)
route, pathParams, err := router.FindRoute(req)
if err != nil {
t.Fatalf("find route for %s %s: %v", c.method, req.URL.Path, err)
}
requestInput := &openapi3filter.RequestValidationInput{
Request: req,
PathParams: pathParams,
Route: route,
Options: &openapi3filter.Options{
AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
},
}
if err := openapi3filter.ValidateRequest(req.Context(), requestInput); err != nil {
t.Fatalf("ValidateRequest %s %s (%s): %v", c.method, c.path, c.operationID, err)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
expectPlaceholder := groupForPath(c.path) != "probe"
if expectPlaceholder {
if got, want := recorder.Code, http.StatusNotImplemented; got != want {
t.Fatalf("operation %s %s (%s) returned status %d, want %d (body: %s)",
c.method, c.path, c.operationID, got, want, recorder.Body.String())
}
} else if recorder.Code/100 != 2 {
t.Fatalf("probe operation %s %s (%s) returned non-2xx status %d (body: %s)",
c.method, c.path, c.operationID, recorder.Code, recorder.Body.String())
}
if ct := recorder.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("operation %s %s (%s) returned Content-Type %q, want application/json", c.method, c.path, c.operationID, ct)
}
responseInput := &openapi3filter.ResponseValidationInput{
RequestValidationInput: requestInput,
Status: recorder.Code,
Header: recorder.Header(),
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
responseInput.SetBodyBytes(recorder.Body.Bytes())
if err := openapi3filter.ValidateResponse(req.Context(), responseInput); err != nil {
t.Fatalf("ValidateResponse %s %s (%s): %v", c.method, c.path, c.operationID, err)
}
}
func buildRequest(t *testing.T, c contractOperation) *http.Request {
t.Helper()
target := substitutePathParams(t, c.path)
if query := buildQuery(t, c); query != "" {
target += "?" + query
}
url := "http://backend.internal" + target
body := bodyFor(t, c)
req, err := http.NewRequest(c.method, url, body.reader)
if err != nil {
t.Fatalf("construct request %s %s: %v", c.method, url, err)
}
if body.contentType != "" {
req.Header.Set("Content-Type", body.contentType)
}
req.Header.Set("Accept", "application/json")
switch groupForPath(c.path) {
case "user":
req.Header.Set("X-User-ID", stubUserID)
case "admin":
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("operator:"+contractStubAdminPassword)))
}
req = req.WithContext(context.Background())
return req
}
type requestBody struct {
reader io.Reader
contentType string
}
func bodyFor(t *testing.T, c contractOperation) requestBody {
t.Helper()
if c.op.RequestBody == nil || c.op.RequestBody.Value == nil {
return requestBody{}
}
media := c.op.RequestBody.Value.Content.Get("application/json")
if media == nil {
return requestBody{}
}
stub, ok := requestBodyStubs[c.operationID]
if !ok {
stub = map[string]any{}
}
encoded, err := json.Marshal(stub)
if err != nil {
t.Fatalf("marshal request body for %s: %v", c.operationID, err)
}
return requestBody{
reader: bytes.NewReader(encoded),
contentType: "application/json",
}
}
func buildQuery(t *testing.T, c contractOperation) string {
t.Helper()
if c.op == nil {
return ""
}
values := make([]string, 0, len(c.op.Parameters))
for _, p := range c.op.Parameters {
if p == nil || p.Value == nil {
continue
}
if p.Value.In != "query" {
continue
}
stub, ok := queryParamStubs[p.Value.Name]
if !ok {
if p.Value.Required {
t.Fatalf("operation %q requires query parameter %q with no stub registered", c.operationID, p.Value.Name)
}
continue
}
values = append(values, p.Value.Name+"="+stub)
}
return strings.Join(values, "&")
}
func substitutePathParams(t *testing.T, templated string) string {
t.Helper()
result := templated
for {
open := strings.Index(result, "{")
if open < 0 {
break
}
close := strings.Index(result[open:], "}")
if close < 0 {
t.Fatalf("malformed path template %q", templated)
}
name := result[open+1 : open+close]
value, ok := pathParamStubs[name]
if !ok {
t.Fatalf("path template %q references unknown parameter %q", templated, name)
}
result = result[:open] + value + result[open+close+1:]
}
return result
}
// groupForPath returns the route family that contractOperation.path belongs
// to. The classification drives test-side header injection (X-User-ID,
// Authorization).
func groupForPath(path string) string {
switch {
case strings.HasPrefix(path, "/api/v1/public"):
return "public"
case strings.HasPrefix(path, "/api/v1/user"):
return "user"
case strings.HasPrefix(path, "/api/v1/admin"):
return "admin"
case strings.HasPrefix(path, "/api/v1/internal"):
return "internal"
default:
return "probe"
}
}