test: verify offline matchmaking pipeline

This commit is contained in:
Josh Creek
2026-08-31 21:04:22 +01:00
parent c0d1c33f54
commit 723f8aea5d
4 changed files with 96 additions and 2 deletions
+1 -1
View File
@@ -1240,7 +1240,7 @@ the local/CI/community transport, not a silent production fallback.
| 8.45 `[D:8.2,8.44]` | **IN PROGRESS.** Go observability package turns the documented RTT, allocation/connect latency, result-success, API-latency and tick/headroom thresholds into executable window checks | `server/observability/slo.go` covers healthy/violating/empty windows; production metrics export, dashboards, alert routing, wait/MMR/proposal/flood/cost series and runbooks remain |
| 8.46 `[D:8.5,8.7,8.9,8.10,8.14,8.18,8.21,8.23,8.25]` | **IN PROGRESS.** Go unit/race coverage spans the current domain/store/supervisor policies, and fuzz targets now exercise queue input, result payload hashing and revision events | `server/domain/*_test.go`, `server/store/*_test.go`, `server/supervisor/*_test.go` and `server/domain/fuzz_test.go` pass normal/race suites; `go test -fuzz`, PostgreSQL concurrency/migration execution, fake Steam/allocator and full lost-Redis/transaction fixtures remain |
| 8.47 `[D:8.7,8.30]` | **IN PROGRESS.** Offline testkit provides deterministic fake Steam verification and fake allocation with forced failure injection | `server/testkit/` covers verified identity/replay and cloud-free forced allocation failure; API/Compose integration and exhaustive success/failure matrix remain |
| 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | Second Compose flow: fake backend → queue/proposal → process-ready/allocation/assignment-ready → ENet roster → result ack → shutdown; do not edit Phase 6 fixture | Both server models have independent green gates; existing Make invocations remain unchanged |
| 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | **IN PROGRESS.** Offline testkit exercises verified queue projection → ranked six-player proposal → ENet allocationassignment-ready manifest → certified durable result receipt | `server/testkit/pipeline_test.go` covers the cross-domain success path without Steam/cloud secrets; independent Compose fixture, process shutdown, result ack over HTTP and legacy fixture non-regression remain |
| 8.49 `[D:8.25,8.26,8.28,8.29,8.30,8.31,8.35,8.36]` | Disposable `kind` + Agones integration gate | CI covers dynamic ports, both readiness stages, roster/no-show, races, multi-match node, result-pending reconciliation, drain and rollback |
| 8.50 `[D:8.25,8.37,8.43,8.49]` | Network/chaos suite: 100 ms RTT, jitter/loss, client/API/matcher restart, game-pod death, node drain, Redis failover and control-plane loss | System recovers to a defined state; infrastructure-caused cases cannot penalise affected players |
| 8.51 `[D:8.17,8.18,8.30,8.31,8.45]` | Load test >=10,000 queued clients, >=100 proposals/s and forecast launch concurrency x2 | API p95 <=250 ms, durable matcher fence holds, both readiness/allocation SLOs are met and replicas scale without duplicate claims |
+9 -1
View File
@@ -147,7 +147,15 @@ func (p *Proposal) allAccepted() bool {
func (p *Proposal) copy() Proposal {
clone := *p
clone.Participants = append([]ProposalParticipant(nil), p.Participants...)
clone.idempotent = nil
if p.idempotent != nil {
clone.idempotent = make(map[string]proposalMutation, len(p.idempotent))
for key, mutation := range p.idempotent {
prior := mutation.proposal
prior.Participants = append([]ProposalParticipant(nil), prior.Participants...)
prior.idempotent = nil
clone.idempotent[key] = proposalMutation{digest: mutation.digest, proposal: prior}
}
}
return clone
}
+17
View File
@@ -45,6 +45,23 @@ func TestProposalResponseReplayIsStableAndPayloadReuseConflicts(t *testing.T) {
}
}
func TestReturnedProposalRetainsIdempotencyStateForChainedResponses(t *testing.T) {
now := time.Unix(1000, 0)
p, err := NewProposal("proposal-123456789", Ranked, []string{"a", "b", "c", "d", "e", "f"}, now)
if err != nil {
t.Fatal(err)
}
for _, playerID := range []string{"a", "b", "c", "d", "e", "f"} {
p, err = p.Respond(playerID, "accept-"+playerID+"-123456", true, p.Revision, now)
if err != nil {
t.Fatal(err)
}
}
if p.State != Accepted || p.Revision != 6 {
t.Fatalf("chained responses = %+v", p)
}
}
func TestProposalExpiryTimesOutPendingParticipantsAndClosesRace(t *testing.T) {
now := time.Unix(1000, 0)
p, err := NewProposal("proposal-123456789", Ranked, []string{"a", "b", "c", "d", "e", "f"}, now)
+69
View File
@@ -0,0 +1,69 @@
package testkit
import (
"testing"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
func TestOfflineMatchmakingPipelineReachesDurableResult(t *testing.T) {
now := time.Unix(1000, 0)
queue := domain.NewQueue()
for i := 0; i < 6; i++ {
playerID := string(rune('a' + i))
ticketID := "ticket-" + playerID
candidate := domain.Candidate{TicketID: ticketID, PlayerID: playerID, Rating: 1500 + float64(i), EnqueuedAt: now, PredictedRTT: map[string]float64{"EU": 30}}
if _, err := queue.Create(playerID, ticketID, "create-key-"+playerID+"-123456", candidate, now); err != nil {
t.Fatal(err)
}
}
candidates := queue.Candidates(now)
selection, err := domain.SelectCandidates(candidates[0], candidates[1:], 6, now)
if err != nil || len(selection.Players) != 6 || selection.Region != "EU" {
t.Fatalf("selection = %+v err=%v", selection, err)
}
playerIDs := make([]string, 0, len(selection.Players))
for _, candidate := range selection.Players {
playerIDs = append(playerIDs, candidate.PlayerID)
}
proposal, err := domain.NewProposal("proposal-1234567890123456", domain.Ranked, playerIDs, now)
if err != nil {
t.Fatal(err)
}
for _, participant := range proposal.Participants {
proposal, err = proposal.Respond(participant.PlayerID, "accept-"+participant.PlayerID+"-123456", true, proposal.Revision, now)
if err != nil {
t.Fatal(err)
}
}
if proposal.State != domain.Accepted {
t.Fatalf("proposal did not accept: %+v", proposal)
}
allocator, err := domain.NewAllocator([]domain.ReadyServer{{ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}})
if err != nil {
t.Fatal(err)
}
allocation, err := allocator.Allocate(domain.AllocationRequest{AllocationID: "allocation-1234567890123456", MatchID: "match-1234567890123456", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, now)
if err != nil {
t.Fatal(err)
}
manifest := domain.AllocationManifest{AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, ServerID: allocation.ServerID, Region: allocation.Region, Build: allocation.Build, Protocol: allocation.Protocol, Transport: allocation.Transport, RosterDigest: "roster-digest"}
digest := domain.ManifestDigest(manifest)
assignment, err := domain.VerifyAssignment(allocation, manifest, "127.0.0.1:31001", digest[:], func(_, signature []byte) bool { return string(signature) == string(digest[:]) })
if err != nil || assignment.Endpoint == "" {
t.Fatalf("assignment = %+v err=%v", assignment, err)
}
store, err := domain.NewResultStore(domain.WorkloadBinding{Issuer: "issuer", Audience: "audience", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: allocation.MatchID, ServerID: allocation.ServerID})
if err != nil {
t.Fatal(err)
}
result := domain.MatchResult{MatchID: allocation.MatchID, ServerID: allocation.ServerID, ResultNonce: "result-nonce-123456", Team0Score: 3, Team1Score: 2, IntegrityState: domain.IntegrityCertified}
receipt, created, err := store.Submit("result-1234567890123456", result, domain.WorkloadBinding{Issuer: "issuer", Audience: "audience", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: allocation.MatchID, ServerID: allocation.ServerID}, now)
if err != nil || !created || !domain.RatingEligible(receipt) {
t.Fatalf("receipt = %+v created=%v err=%v", receipt, created, err)
}
}