mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 22:43:46 +00:00
6d3490da14
ProposalParticipantExpireSQL marked every PENDING participant on a proposal TIMED_OUT unconditionally -- it took a proposal_id and 'now' but never actually compared 'now' against the proposal's expires_at, unlike its sibling ProposalExpireSQL (which does gate on 'expires_at <= $2'). Both GetProposal and RespondToProposal run this statement on every call as a recovery step, so the very first RespondToProposal for any proposal timed out every participant (including the one about to respond) before checking their response, then rejected the real accept/decline with ErrConflict. Add the same expiry gate via an EXISTS against proposals.expires_at, matching ProposalExpireSQL's own condition, and update the SQL-fragment test to assert the gate is present. Verified end to end against a real PostgreSQL instance: TestPostgreSQLProposalClaimAndResponseAreAtomic now passes a two-participant accept/accept sequence that previously failed on the first response.
36 lines
1.5 KiB
Go
36 lines
1.5 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'"},
|
|
} {
|
|
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")
|
|
}
|
|
}
|