mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 21:53:43 +00:00
4627dd58fb
The timeout sibling of the previous commit's decline fix: a proposal that simply times out (the 10s window elapses with no unanimous response) hits ProposalExpireSQL/ProposalParticipantExpireSQL, and neither of those -- same as the decline path -- ever touched queue_tickets. Same severe consequence: every participant still holding a PROPOSED ticket, response pending or already accepted, is left stranded (invisible to the matcher, blocking a fresh queue_create, renewable forever by heartbeat) with no automatic way back into matchmaking. This path is reached from both GetProposal (the recovery/read boundary -- a client that missed the expiry event entirely) and RespondToProposal (a response arriving after the window), so both needed the fix. ProposalExpireRequeueSQL mirrors ProposalDeclineRequeueSQL, guarded on state = 'EXPIRED' so it's safe to call unconditionally right after ProposalExpireSQL: a no-op on a proposal that's still OPEN, and a no-op on a proposal that was already EXPIRED on a prior pass (nothing left at PROPOSED to requeue a second time). Covered by a real PostgreSQL integration test via GetProposal (nobody ever responds; recovering the proposal well after its window expires it and must requeue both participants), confirming both tickets land back at QUEUED with a refreshed expiry and are visible again to ListQueuedCandidates. Clean across 5 runs, plus the full integration and unit suites.
38 lines
1.7 KiB
Go
38 lines
1.7 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestProposalRecoverySQLBindsParticipantAndExpiresAtReadBoundary(t *testing.T) {
|
|
for query, fragments := range map[string][]string{
|
|
ProposalExpireSQL: {"state = 'OPEN'", "expires_at <= $2", "revision = revision + 1"},
|
|
ProposalParticipantExpireSQL: {"response = 'PENDING'", "response = 'TIMED_OUT'", "proposals.expires_at <= $2"},
|
|
ProposalRecoverySelectSQL: {"proposal_id = $1", "player_id = $2", "EXISTS"},
|
|
ProposalParticipantsSelectSQL: {"proposal_id = $1", "ORDER BY player_id"},
|
|
ProposalResponseIdempotencyInsertSQL: {"ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"},
|
|
ProposalLockSQL: {"proposal_id = $1", "FOR UPDATE"},
|
|
ProposalParticipantLockSQL: {"proposal_id = $1", "player_id = $2", "FOR UPDATE"},
|
|
ProposalParticipantRespondSQL: {"response = 'PENDING'", "responded_at"},
|
|
ProposalRevisionBumpSQL: {"revision = revision + 1", "state = 'OPEN'"},
|
|
ProposalDeclineRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "proposal_participants"},
|
|
ProposalExpireRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "state = 'EXPIRED'"},
|
|
} {
|
|
for _, fragment := range fragments {
|
|
if !contains(query, fragment) {
|
|
t.Fatalf("query %q missing %q", query, fragment)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProposalRecoveryRejectsMissingAuthorityInputs(t *testing.T) {
|
|
if _, err := GetProposal(nil, nil, "player-1", "proposal-1", time.Unix(1000, 0)); err == nil {
|
|
t.Fatal("nil database accepted")
|
|
}
|
|
if _, err := GetProposal(nil, nil, "", "proposal-1", time.Unix(1000, 0)); err == nil {
|
|
t.Fatal("empty player accepted")
|
|
}
|
|
}
|