mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
d937cb153c
Add POST /v1/servers/{id}/register (and its /api/v1 contract alias),
authenticated by the same workload binding as the result route. A
game server reports its protocol version and image digest and asks
to advance ALLOCATING -> PROCESS_READY -> ASSIGNMENT_READY; the store
boundary (AdvanceServerRegistration) does this as one idempotent
SERIALIZABLE transaction that also advances every participant's queue
ticket, and gates the final transition on every participant having a
live, unexpired assignment.
Adversarial review of the surrounding routing turned up a pre-existing
bug: contractServerMutation rejected any path containing '/', so the
already-documented /api/v1/servers/{id}/result route (and this new
/register route) 404'd for every real caller despite being declared
in the OpenAPI contract. Fix it to delegate shape validation to
serverMutation, matching how contractQueueMutation handles its own
two-segment paths, and add a regression test covering both contract
routes end to end.
48 lines
2.1 KiB
Go
48 lines
2.1 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
)
|
|
|
|
func TestAllocationMatchClaimSQLFencesConcurrentWorkers(t *testing.T) {
|
|
checks := map[string][]string{
|
|
ClaimAllocatingMatchSQL: {"FOR UPDATE SKIP LOCKED", "allocation_id = 'allocation-' || candidate.match_id", "allocation_claimed_at <= $1", "ORDER BY created_at, match_id"},
|
|
AllocatingMatchBuildSQL: {"match_participants", "queue_tickets", "ORDER BY q.client_build"},
|
|
BindAllocatedMatchParticipantsSQL: {"allocation_id = $2", "server_id IS NULL", "SET server_id = $3", "FROM allocations", "state = 'ALLOCATING'", "revision = revision + 1"},
|
|
ReleaseAllocatedMatchClaimSQL: {"allocation_id = $2", "allocation_id = NULL", "allocation_claimed_at = NULL"},
|
|
AdvanceServerRegistrationSQL: {"state = $4", "protocol_version = $7", "ASSIGNMENT_READY", "revision = revision + 1"},
|
|
ServerRegistrationIdempotencyInsertSQL: {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
|
ServerRegistrationIdempotencySelectSQL: {"scope = $1", "idempotency_key = $2", "FOR UPDATE"},
|
|
}
|
|
for query, fragments := range checks {
|
|
for _, fragment := range fragments {
|
|
if !contains(query, fragment) {
|
|
t.Fatalf("query missing %q", fragment)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllocationMatchClaimRejectsInvalidArgumentsWithoutDatabase(t *testing.T) {
|
|
now := time.Unix(1_000, 0)
|
|
if _, _, err := ClaimAllocatingMatch(nil, nil, "enet", now); err == nil {
|
|
t.Fatal("nil database accepted")
|
|
}
|
|
if _, _, err := ClaimAllocatingMatch(nil, nil, "udp", now); err == nil {
|
|
t.Fatal("invalid transport accepted")
|
|
}
|
|
if _, _, err := ClaimAllocatingMatch(nil, nil, "enet", time.Time{}); err == nil {
|
|
t.Fatal("zero claim time accepted")
|
|
}
|
|
allocated := domain.Allocation{AllocationID: "allocation-match-1", MatchID: "match-1", ServerID: "server-1", State: domain.ServerAllocated}
|
|
if err := BindAllocatedMatch(nil, nil, allocated); err == nil {
|
|
t.Fatal("nil database accepted for bind")
|
|
}
|
|
if err := ReleaseAllocatedMatchClaim(nil, nil, "match-1", "allocation-match-1"); err == nil {
|
|
t.Fatal("nil database accepted for release")
|
|
}
|
|
}
|