feat: persist participant-scoped proposal recovery

This commit is contained in:
Josh Creek
2026-09-01 07:50:11 +01:00
parent 1eb6e8f9f4
commit 30b4560bd5
7 changed files with 217 additions and 20 deletions
+31 -18
View File
@@ -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
+56
View File
@@ -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())
+11
View File
@@ -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}
}
+10
View File
@@ -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")
}
}