Files
CosmicClash/server/store/outbox_envelope_test.go
T
Josh Creek 1dd05c75f1 fix(server): repair the initial-connect outbox envelope and unblock dispatch
ApplyInitialConnectPlan wrote a payload of {match_id,state,action},
omitting event, revision, resource_id, occurred_at and player_ids --
every field deliverStateOutboxEvent requires. Delivery rejected the row,
dispatch returned on the first error so it was never acknowledged, and
because reads are ordered oldest-first it was retried ahead of every
later state_changed event on every 100ms poll. One initial-connect
transition therefore blocked lifecycle delivery for all matches, not
just its own.

Two independent fixes, since either alone leaves the system fragile:

Build envelopes through one validating helper (MarshalOutboxEnvelope)
and convert all five writers to it. A writer that omits a required
field now fails its own transaction instead of committing a row that
can only ever poison the queue. The helper takes revision as int64 so
the -1 "nothing matched" sentinel some CTEs return surfaces as an error
rather than wrapping to a huge uint64.

Make dispatch resilient regardless: a delivery failure is now counted
against that row and the batch continues, with the row dead-lettered
after MaxOutboxDeliveryAttempts so a poison event degrades to one lost
notification instead of a stalled queue. Ordering within an aggregate
is still honoured -- later events of a failed match are deferred, so no
client observes that match's newer state before its older state. An ack
failure still stops the batch, being a database rather than a payload
problem.

Initial-connect events now address every participant, not just the
connected ones: a no-show needs to learn their ticket was failed and a
penalty applied.
2026-09-05 10:17:16 +01:00

89 lines
3.4 KiB
Go

package store
import (
"encoding/json"
"testing"
"time"
)
func TestMarshalStateChangedEnvelopeProducesDeliverableShape(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
payload, err := MarshalStateChangedEnvelope("match-1", 7, "LIVE", now, []string{"player-a", "player-b"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Decode with exactly the struct api.deliverStateOutboxEvent uses, then
// apply exactly its acceptance predicate. This is the regression guard for
// the initial-connect payload that omitted every one of these keys.
var envelope struct {
Event string `json:"event"`
Revision uint64 `json:"revision"`
ResourceID string `json:"resource_id"`
OccurredAt time.Time `json:"occurred_at"`
State string `json:"state"`
MatchID string `json:"match_id"`
PlayerIDs []string `json:"player_ids"`
}
if err := json.Unmarshal(payload, &envelope); err != nil {
t.Fatalf("decode: %v", err)
}
if envelope.Event != "state_changed" || envelope.ResourceID != "match-1" || envelope.Revision != 7 ||
envelope.State == "" || len(envelope.PlayerIDs) == 0 {
t.Fatalf("envelope would be rejected by the dispatcher: %+v", envelope)
}
if envelope.MatchID != "match-1" || !envelope.OccurredAt.Equal(now) {
t.Fatalf("unexpected envelope: %+v", envelope)
}
}
func TestMarshalOutboxEnvelopeRejectsUndeliverablePayloads(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
valid := OutboxEnvelope{
Event: "state_changed", ResourceID: "match-1", Revision: 1,
OccurredAt: now, State: "LIVE", MatchID: "match-1", PlayerIDs: []string{"player-a"},
}
if _, err := MarshalOutboxEnvelope(valid); err != nil {
t.Fatalf("baseline envelope must be valid: %v", err)
}
for name, mutate := range map[string]func(*OutboxEnvelope){
"no event": func(e *OutboxEnvelope) { e.Event = "" },
"no resource": func(e *OutboxEnvelope) { e.ResourceID = "" },
"no state": func(e *OutboxEnvelope) { e.State = "" },
"no timestamp": func(e *OutboxEnvelope) { e.OccurredAt = time.Time{} },
"no recipients": func(e *OutboxEnvelope) { e.PlayerIDs = nil },
"empty recipient": func(e *OutboxEnvelope) { e.PlayerIDs = []string{"player-a", ""} },
"duplicate recipient": func(e *OutboxEnvelope) { e.PlayerIDs = []string{"player-a", "player-a"} },
// -1 is the "nothing matched" sentinel several CTEs return. Untyped as
// uint64 it would become 18446744073709551615 in the payload.
"sentinel revision": func(e *OutboxEnvelope) { e.Revision = -1 },
"reserved extra": func(e *OutboxEnvelope) { e.Extra = map[string]any{"state": "CANCELLED"} },
} {
t.Run(name, func(t *testing.T) {
envelope := valid
mutate(&envelope)
if _, err := MarshalOutboxEnvelope(envelope); err == nil {
t.Fatalf("expected %s to be rejected at construction", name)
}
})
}
}
func TestMarshalOutboxEnvelopeOmitsMatchIDForProposals(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
payload, err := MarshalOutboxEnvelope(OutboxEnvelope{
Event: "proposal_changed", ResourceID: "proposal-1", Revision: 0,
OccurredAt: now, State: "OPEN", PlayerIDs: []string{"player-a"},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode: %v", err)
}
if _, present := decoded["match_id"]; present {
t.Fatalf("proposal envelope must not carry a match_id: %s", payload)
}
}