mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 00:03:43 +00:00
feat: persist participant-scoped proposal recovery
This commit is contained in:
+31
-18
@@ -42,6 +42,9 @@ type SteamLoginProvider interface {
|
||||
type SessionIssuer interface {
|
||||
Issue(context.Context, string, time.Duration, time.Time) (domain.Session, string, error)
|
||||
}
|
||||
type ProposalBackend interface {
|
||||
Get(context.Context, string, string, time.Time) (domain.Proposal, error)
|
||||
}
|
||||
|
||||
type AssignmentView struct {
|
||||
MatchID string `json:"match_id"`
|
||||
@@ -57,24 +60,25 @@ type AssignmentView struct {
|
||||
type AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error)
|
||||
|
||||
type Service struct {
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
Probe ProbeProvider
|
||||
Assignment AssignmentProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
RankedProfiles map[string]domain.RankedProfile
|
||||
TierPolicy domain.TierPolicy
|
||||
RateLimiter *RateLimiter
|
||||
proposalMu sync.Mutex
|
||||
eventsMu sync.Mutex
|
||||
events *eventHub
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
Probe ProbeProvider
|
||||
Assignment AssignmentProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
ProposalBackend ProposalBackend
|
||||
RankedProfiles map[string]domain.RankedProfile
|
||||
TierPolicy domain.TierPolicy
|
||||
RateLimiter *RateLimiter
|
||||
proposalMu sync.Mutex
|
||||
eventsMu sync.Mutex
|
||||
events *eventHub
|
||||
}
|
||||
|
||||
func (s *Service) Handler() http.Handler {
|
||||
@@ -413,6 +417,15 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) {
|
||||
s.proposalMu.Lock()
|
||||
defer s.proposalMu.Unlock()
|
||||
proposal, exists := s.Proposals[parts[0]]
|
||||
if s.ProposalBackend != nil {
|
||||
proposalValue, providerErr := s.ProposalBackend.Get(r.Context(), playerID, parts[0], s.now())
|
||||
if providerErr != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
proposal = &proposalValue
|
||||
exists = true
|
||||
}
|
||||
if !exists || proposal == nil || !proposal.HasParticipant(playerID) {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
|
||||
@@ -21,6 +21,19 @@ type queueBackendSpy struct{ createCalls, heartbeatCalls, cancelCalls, getCalls
|
||||
|
||||
type sessionBackendSpy struct{ calls int }
|
||||
|
||||
type proposalBackendSpy struct {
|
||||
proposal domain.Proposal
|
||||
calls int
|
||||
}
|
||||
|
||||
func (b *proposalBackendSpy) Get(_ context.Context, playerID, _ string, _ time.Time) (domain.Proposal, error) {
|
||||
b.calls++
|
||||
if !b.proposal.HasParticipant(playerID) {
|
||||
return domain.Proposal{}, domain.ErrNotParticipant
|
||||
}
|
||||
return b.proposal, nil
|
||||
}
|
||||
|
||||
func (s *sessionBackendSpy) Authenticate(_ context.Context, sessionID, _ string, _ time.Time) (domain.Session, error) {
|
||||
s.calls++
|
||||
return domain.Session{SessionID: sessionID, PlayerID: "player-1"}, nil
|
||||
@@ -341,6 +354,49 @@ func TestStateChangingAPIActionsPublishTargetedEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
proposal, err := domain.NewProposal("proposal-1234567890123456", domain.Casual, []string{"player-1", "player-2"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &proposalBackendSpy{proposal: proposal}
|
||||
sessions := domain.NewSessionStore()
|
||||
participantSession, participantToken, err := sessions.Issue("player-1", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsiderSession, outsiderToken, err := sessions.Issue("outsider", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{Sessions: sessions, Proposals: map[string]*domain.Proposal{}, ProposalBackend: backend, Now: func() time.Time { return now }}
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
get := func(session domain.Session, token string) int {
|
||||
request, err := http.NewRequest(http.MethodGet, server.URL+"/v1/proposals/"+proposal.ProposalID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
|
||||
response, err := server.Client().Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
return response.StatusCode
|
||||
}
|
||||
if status := get(participantSession, participantToken); status != http.StatusOK {
|
||||
t.Fatalf("participant recovery status = %d", status)
|
||||
}
|
||||
if status := get(outsiderSession, outsiderToken); status != http.StatusNotFound {
|
||||
t.Fatalf("outsider recovery status = %d", status)
|
||||
}
|
||||
if backend.calls != 2 {
|
||||
t.Fatalf("durable backend calls = %d", backend.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
|
||||
service := &Service{Sessions: domain.NewSessionStore(), Queue: domain.NewQueue(), Candidate: func(string, string) (domain.Candidate, error) { return domain.Candidate{}, nil }}
|
||||
server := httptest.NewServer(service.Handler())
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
)
|
||||
|
||||
@@ -29,3 +30,13 @@ func AssignmentProviderFromStore(db *sql.DB) AssignmentProvider {
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type postgresProposalBackend struct{ db *sql.DB }
|
||||
|
||||
func (p postgresProposalBackend) Get(ctx context.Context, playerID, proposalID string, now time.Time) (domain.Proposal, error) {
|
||||
return store.GetProposal(ctx, p.db, playerID, proposalID, now)
|
||||
}
|
||||
|
||||
func ProposalProviderFromStore(db *sql.DB) ProposalBackend {
|
||||
return postgresProposalBackend{db: db}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,13 @@ func TestAssignmentProviderFromStorePreservesPlayerScopedRecoveryBoundary(t *tes
|
||||
t.Fatal("nil store was treated as an available assignment source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalProviderFromStoreFailsClosedWithoutDatabase(t *testing.T) {
|
||||
provider := ProposalProviderFromStore(nil)
|
||||
if provider == nil {
|
||||
t.Fatal("proposal store provider was not created")
|
||||
}
|
||||
if _, err := provider.Get(context.Background(), "player-1", "proposal-1", time.Unix(1000, 0)); err == nil {
|
||||
t.Fatal("nil store was treated as an available proposal source")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
const ProposalExpireSQL = `UPDATE proposals
|
||||
SET state = 'EXPIRED', revision = revision + 1
|
||||
WHERE proposal_id = $1 AND state = 'OPEN' AND expires_at <= $2`
|
||||
|
||||
const ProposalParticipantExpireSQL = `UPDATE proposal_participants
|
||||
SET response = 'TIMED_OUT', responded_at = $2
|
||||
WHERE proposal_id = $1 AND response = 'PENDING'`
|
||||
|
||||
const ProposalRecoverySelectSQL = `SELECT proposal_id, playlist, state, revision, expires_at
|
||||
FROM proposals
|
||||
WHERE proposal_id = $1
|
||||
AND EXISTS (SELECT 1 FROM proposal_participants WHERE proposal_id = proposals.proposal_id AND player_id = $2)`
|
||||
|
||||
const ProposalParticipantsSelectSQL = `SELECT player_id, response
|
||||
FROM proposal_participants
|
||||
WHERE proposal_id = $1
|
||||
ORDER BY player_id`
|
||||
|
||||
// GetProposal recovers the full proposal only after proving the caller is a
|
||||
// participant. Expiry is advanced in the same transaction as the read so a
|
||||
// missed event cannot leave a durable proposal indefinitely OPEN.
|
||||
func GetProposal(ctx context.Context, db *sql.DB, playerID, proposalID string, now time.Time) (domain.Proposal, error) {
|
||||
if db == nil || playerID == "" || proposalID == "" || now.IsZero() {
|
||||
return domain.Proposal{}, fmt.Errorf("invalid proposal recovery arguments")
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, ProposalExpireSQL, proposalID, now); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, ProposalParticipantExpireSQL, proposalID, now); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
var proposal domain.Proposal
|
||||
var playlist, state string
|
||||
if err := tx.QueryRowContext(ctx, ProposalRecoverySelectSQL, proposalID, playerID).Scan(&proposal.ProposalID, &playlist, &state, &proposal.Revision, &proposal.ExpiresAt); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
proposal.Playlist = domain.Playlist(playlist)
|
||||
proposal.State = domain.State(state)
|
||||
rows, err := tx.QueryContext(ctx, ProposalParticipantsSelectSQL, proposalID)
|
||||
if err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var participant domain.ProposalParticipant
|
||||
if err := rows.Scan(&participant.PlayerID, &participant.Response); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
proposal.Participants = append(proposal.Participants, participant)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
if len(proposal.Participants) == 0 {
|
||||
return domain.Proposal{}, fmt.Errorf("proposal has no participants")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Proposal{}, err
|
||||
}
|
||||
return proposal, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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'"},
|
||||
ProposalRecoverySelectSQL: {"proposal_id = $1", "player_id = $2", "EXISTS"},
|
||||
ProposalParticipantsSelectSQL: {"proposal_id = $1", "ORDER BY player_id"},
|
||||
} {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user