mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user