mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
79318b56bd
The last two commits fixed the severe stranding bug in decline and timeout, but left a real responsiveness gap: cancelling a ticket directly while it's part of an OPEN proposal used to leave the OTHER participant waiting out the full response window for something the system already knew couldn't happen -- their proposal partner just abandoned the queue. ProposalExpireRequeueSQL eventually rescues them, but only after the full window elapses, not immediately. CascadeCancelToOpenProposal runs inside the same transaction as the cancel itself: if the cancelled ticket belonged to a currently-OPEN proposal, decline that proposal right now and requeue every other participant immediately via the same ProposalDeclineRequeueSQL the decline path already uses. The cancelling player's own ticket correctly stays CANCELLED, not swept back into the requeue meant for everyone else (ProposalDeclineRequeueSQL only touches tickets still at PROPOSED). Covered by a real PostgreSQL integration test: cancelling one participant's ticket mid-proposal immediately declines the proposal and requeues the other participant with a refreshed expiry, while the cancelling player's own ticket stays CANCELLED. First draft used a stale expected revision (0) for the cancel call -- CreateProposal's own QueueTicketProposeSQL already bumps a ticket's revision to 1 when forming the proposal, caught immediately by actually running the test against real Postgres rather than assuming. Clean across 5 runs after the fix, plus the full integration and unit suites.
39 lines
1.8 KiB
Go
39 lines
1.8 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'"},
|
|
OpenProposalForCancelledTicketSQL: {"proposal_participants", "state = 'OPEN'"},
|
|
} {
|
|
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")
|
|
}
|
|
}
|