290 lines
7.4 KiB
Go
290 lines
7.4 KiB
Go
package shared_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/adapters/gameinmem"
|
|
"galaxy/lobby/internal/adapters/inviteinmem"
|
|
"galaxy/lobby/internal/adapters/mocks"
|
|
"galaxy/lobby/internal/domain/common"
|
|
"galaxy/lobby/internal/domain/game"
|
|
"galaxy/lobby/internal/domain/invite"
|
|
"galaxy/lobby/internal/ports"
|
|
"galaxy/lobby/internal/service/shared"
|
|
"galaxy/notificationintent"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/mock/gomock"
|
|
)
|
|
|
|
const (
|
|
closeOwnerUserID = "user-owner"
|
|
closeGameID = common.GameID("game-private")
|
|
closeGameName = "Friends Only"
|
|
)
|
|
|
|
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
|
|
|
type intentRec struct {
|
|
mu sync.Mutex
|
|
published []notificationintent.Intent
|
|
err error
|
|
}
|
|
|
|
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.err != nil {
|
|
return "", r.err
|
|
}
|
|
r.published = append(r.published, intent)
|
|
return "1", nil
|
|
}
|
|
|
|
func (r *intentRec) snapshot() []notificationintent.Intent {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]notificationintent.Intent(nil), r.published...)
|
|
}
|
|
|
|
func (r *intentRec) setErr(err error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.err = err
|
|
}
|
|
|
|
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
|
|
t.Helper()
|
|
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
|
|
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
|
|
return m
|
|
}
|
|
|
|
type closeFixture struct {
|
|
now time.Time
|
|
games *gameinmem.Store
|
|
invites *inviteinmem.Store
|
|
intentRec *intentRec
|
|
intents *mocks.MockIntentPublisher
|
|
game game.Game
|
|
}
|
|
|
|
func newCloseFixture(t *testing.T) *closeFixture {
|
|
t.Helper()
|
|
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
|
games := gameinmem.NewStore()
|
|
invites := inviteinmem.NewStore()
|
|
rec := &intentRec{}
|
|
intents := newIntentMock(t, rec)
|
|
|
|
gameRecord, err := game.New(game.NewGameInput{
|
|
GameID: closeGameID,
|
|
GameName: closeGameName,
|
|
GameType: game.GameTypePrivate,
|
|
OwnerUserID: closeOwnerUserID,
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 2,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
|
TurnSchedule: "0 */6 * * *",
|
|
TargetEngineVersion: "1.0.0",
|
|
Now: now,
|
|
})
|
|
require.NoError(t, err)
|
|
gameRecord.Status = game.StatusEnrollmentOpen
|
|
require.NoError(t, games.Save(context.Background(), gameRecord))
|
|
|
|
return &closeFixture{
|
|
now: now,
|
|
games: games,
|
|
invites: invites,
|
|
intentRec: rec,
|
|
intents: intents,
|
|
game: gameRecord,
|
|
}
|
|
}
|
|
|
|
func (f *closeFixture) addCreatedInvite(t *testing.T, inviteID common.InviteID, inviteeUserID string) invite.Invite {
|
|
t.Helper()
|
|
rec, err := invite.New(invite.NewInviteInput{
|
|
InviteID: inviteID,
|
|
GameID: f.game.GameID,
|
|
InviterUserID: closeOwnerUserID,
|
|
InviteeUserID: inviteeUserID,
|
|
Now: f.now,
|
|
ExpiresAt: f.game.EnrollmentEndsAt,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.invites.Save(context.Background(), rec))
|
|
return rec
|
|
}
|
|
|
|
func (f *closeFixture) deps() shared.CloseEnrollmentDeps {
|
|
return shared.CloseEnrollmentDeps{
|
|
Games: f.games,
|
|
Invites: f.invites,
|
|
Intents: f.intents,
|
|
Logger: silentLogger(),
|
|
}
|
|
}
|
|
|
|
func TestCloseEnrollmentTransitionsGameAndExpiresInvites(t *testing.T) {
|
|
t.Parallel()
|
|
f := newCloseFixture(t)
|
|
f.addCreatedInvite(t, "invite-1", "user-a")
|
|
f.addCreatedInvite(t, "invite-2", "user-b")
|
|
|
|
updated, err := shared.CloseEnrollment(
|
|
context.Background(),
|
|
f.deps(),
|
|
f.game.GameID,
|
|
game.TriggerManual,
|
|
f.now.Add(time.Minute),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, game.StatusReadyToStart, updated.Status)
|
|
|
|
first, err := f.invites.Get(context.Background(), "invite-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, invite.StatusExpired, first.Status)
|
|
|
|
second, err := f.invites.Get(context.Background(), "invite-2")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, invite.StatusExpired, second.Status)
|
|
|
|
intents := f.intentRec.snapshot()
|
|
require.Len(t, intents, 2)
|
|
for _, intent := range intents {
|
|
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intent.NotificationType)
|
|
assert.Equal(t, []string{closeOwnerUserID}, intent.RecipientUserIDs)
|
|
}
|
|
}
|
|
|
|
func TestCloseEnrollmentLeavesNonCreatedInvitesUntouched(t *testing.T) {
|
|
t.Parallel()
|
|
f := newCloseFixture(t)
|
|
created := f.addCreatedInvite(t, "invite-1", "user-a")
|
|
declined := f.addCreatedInvite(t, "invite-2", "user-b")
|
|
|
|
require.NoError(t, f.invites.UpdateStatus(context.Background(), ports.UpdateInviteStatusInput{
|
|
InviteID: declined.InviteID,
|
|
ExpectedFrom: invite.StatusCreated,
|
|
To: invite.StatusDeclined,
|
|
At: f.now,
|
|
}))
|
|
|
|
_, err := shared.CloseEnrollment(
|
|
context.Background(),
|
|
f.deps(),
|
|
f.game.GameID,
|
|
game.TriggerManual,
|
|
f.now.Add(time.Minute),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
createdAfter, err := f.invites.Get(context.Background(), created.InviteID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, invite.StatusExpired, createdAfter.Status)
|
|
|
|
declinedAfter, err := f.invites.Get(context.Background(), declined.InviteID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, invite.StatusDeclined, declinedAfter.Status)
|
|
|
|
intents := f.intentRec.snapshot()
|
|
require.Len(t, intents, 1)
|
|
}
|
|
|
|
func TestCloseEnrollmentSurfacesGameConflict(t *testing.T) {
|
|
t.Parallel()
|
|
f := newCloseFixture(t)
|
|
f.addCreatedInvite(t, "invite-1", "user-a")
|
|
|
|
rec, err := f.games.Get(context.Background(), f.game.GameID)
|
|
require.NoError(t, err)
|
|
rec.Status = game.StatusDraft
|
|
require.NoError(t, f.games.Save(context.Background(), rec))
|
|
|
|
_, err = shared.CloseEnrollment(
|
|
context.Background(),
|
|
f.deps(),
|
|
f.game.GameID,
|
|
game.TriggerManual,
|
|
f.now.Add(time.Minute),
|
|
)
|
|
require.ErrorIs(t, err, game.ErrConflict)
|
|
|
|
stillCreated, err := f.invites.Get(context.Background(), "invite-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, invite.StatusCreated, stillCreated.Status)
|
|
assert.Empty(t, f.intentRec.snapshot())
|
|
}
|
|
|
|
func TestCloseEnrollmentSwallowsIntentPublishFailure(t *testing.T) {
|
|
t.Parallel()
|
|
f := newCloseFixture(t)
|
|
f.addCreatedInvite(t, "invite-1", "user-a")
|
|
f.intentRec.setErr(errors.New("publisher offline"))
|
|
|
|
updated, err := shared.CloseEnrollment(
|
|
context.Background(),
|
|
f.deps(),
|
|
f.game.GameID,
|
|
game.TriggerManual,
|
|
f.now.Add(time.Minute),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, game.StatusReadyToStart, updated.Status)
|
|
|
|
expired, err := f.invites.Get(context.Background(), "invite-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, invite.StatusExpired, expired.Status)
|
|
}
|
|
|
|
func TestCloseEnrollmentIsIdempotentOnSecondCall(t *testing.T) {
|
|
t.Parallel()
|
|
f := newCloseFixture(t)
|
|
f.addCreatedInvite(t, "invite-1", "user-a")
|
|
|
|
_, err := shared.CloseEnrollment(
|
|
context.Background(),
|
|
f.deps(),
|
|
f.game.GameID,
|
|
game.TriggerManual,
|
|
f.now.Add(time.Minute),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Len(t, f.intentRec.snapshot(), 1)
|
|
|
|
_, err = shared.CloseEnrollment(
|
|
context.Background(),
|
|
f.deps(),
|
|
f.game.GameID,
|
|
game.TriggerManual,
|
|
f.now.Add(2*time.Minute),
|
|
)
|
|
require.ErrorIs(t, err, game.ErrConflict)
|
|
assert.Len(t, f.intentRec.snapshot(), 1)
|
|
}
|
|
|
|
func TestCloseEnrollmentRejectsUnknownTrigger(t *testing.T) {
|
|
t.Parallel()
|
|
f := newCloseFixture(t)
|
|
|
|
_, err := shared.CloseEnrollment(
|
|
context.Background(),
|
|
f.deps(),
|
|
f.game.GameID,
|
|
game.Trigger("bogus"),
|
|
f.now,
|
|
)
|
|
require.Error(t, err)
|
|
}
|