Files
mixmaker/backend/internal/application/service_test.go
lemintare fa4edc0df5
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Add player profiles and community settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 21:43:48 +03:00

211 lines
7.0 KiB
Go

package application
import (
"context"
"errors"
"testing"
"time"
"mixmaker/backend/internal/domain"
)
type createEventStore struct {
Store
created domain.Event
err error
}
func (s *createEventStore) CreateEvent(_ context.Context, event domain.Event) (domain.Event, error) {
s.created = event
if s.err != nil {
return domain.Event{}, s.err
}
return event, nil
}
type recordingPublisher struct {
topics []string
}
func (p *recordingPublisher) Publish(topic string, _ any) {
p.topics = append(p.topics, topic)
}
type recordingAnnouncer struct {
events []domain.Event
pings []bool
err error
hasDeadline bool
}
func (a *recordingAnnouncer) AnnounceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
a.events = append(a.events, event)
a.pings = append(a.pings, pingEveryone)
_, a.hasDeadline = ctx.Deadline()
return a.err
}
func TestCreateEventAnnouncesAfterPersistence(t *testing.T) {
store := &createEventStore{}
bus := &recordingPublisher{}
announcer := &recordingAnnouncer{}
service := New(store, bus, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{PingEveryone: true})
if err != nil {
t.Fatal(err)
}
if len(announcer.events) != 1 {
t.Fatalf("expected one announcement, got %d", len(announcer.events))
}
if announcer.events[0].ID != created.ID || created.ID == "" {
t.Fatalf("announced event ID %q does not match created event ID %q", announcer.events[0].ID, created.ID)
}
if !announcer.hasDeadline {
t.Fatal("announcement context must have a timeout")
}
if len(announcer.pings) != 1 || !announcer.pings[0] {
t.Fatalf("expected @everyone option, got %v", announcer.pings)
}
if len(bus.topics) != 1 || bus.topics[0] != "events" {
t.Fatalf("unexpected published topics: %v", bus.topics)
}
}
func TestCreateEventDoesNotAnnouncePersistenceFailure(t *testing.T) {
persistenceErr := errors.New("database unavailable")
store := &createEventStore{err: persistenceErr}
announcer := &recordingAnnouncer{}
service := New(store, &recordingPublisher{}, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
_, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{PingEveryone: true})
if !errors.Is(err, persistenceErr) {
t.Fatalf("expected persistence error, got %v", err)
}
if len(announcer.events) != 0 {
t.Fatalf("expected no announcements, got %d", len(announcer.events))
}
}
func TestCreateEventIgnoresAnnouncementFailure(t *testing.T) {
announcementErr := errors.New("discord unavailable")
announcer := &recordingAnnouncer{err: announcementErr}
service := New(&createEventStore{}, &recordingPublisher{}, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{PingEveryone: true})
if err != nil {
t.Fatalf("announcement failure must not fail event creation: %v", err)
}
if created.ID == "" || len(announcer.events) != 1 {
t.Fatalf("event was not created and announced exactly once: event=%+v announcements=%d", created, len(announcer.events))
}
}
func TestCreateEventCanAnnounceWithoutEveryonePing(t *testing.T) {
announcer := &recordingAnnouncer{}
service := New(&createEventStore{}, &recordingPublisher{}, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
if _, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{}); err != nil {
t.Fatal(err)
}
if len(announcer.events) != 1 || len(announcer.pings) != 1 || announcer.pings[0] {
t.Fatalf("expected one announcement without @everyone, events=%d pings=%v", len(announcer.events), announcer.pings)
}
}
type communitySettingsStore struct {
Store
saved domain.CommunitySettings
audits int
}
func (s *communitySettingsStore) UpdateCommunitySettings(_ context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
s.saved = settings
return settings, nil
}
func (s *communitySettingsStore) AppendAudit(_ context.Context, _, _, _ string, _ any) error {
s.audits++
return nil
}
func TestUpdateCommunitySettingsRequiresAdminAndAudits(t *testing.T) {
store := &communitySettingsStore{}
service := New(store, &recordingPublisher{}, nil)
now := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
if _, err := service.UpdateCommunitySettings(context.Background(), domain.Account{Role: domain.RoleModerator}, ""); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("moderator must be forbidden, got %v", err)
}
out, err := service.UpdateCommunitySettings(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, " https://discord.gg/mixmaker ")
if err != nil {
t.Fatal(err)
}
if out.DiscordInviteURL != "https://discord.gg/mixmaker" || out.UpdatedBy != "admin" || !out.UpdatedAt.Equal(now) {
t.Fatalf("unexpected settings: %+v", out)
}
if store.audits != 1 {
t.Fatalf("expected one audit entry, got %d", store.audits)
}
}
type playerProfileStore struct {
Store
players []domain.Player
saved domain.Player
err error
}
func (s *playerProfileStore) ListPlayers(context.Context) ([]domain.Player, error) {
return s.players, nil
}
func (s *playerProfileStore) UpdatePlayer(_ context.Context, player domain.Player) (domain.Player, error) {
s.saved = player
return player, s.err
}
func TestUpdateOwnProfileTrimsIdentityAndClearsBattleTag(t *testing.T) {
store := &playerProfileStore{players: []domain.Player{{ID: "player"}}}
service := New(store, &recordingPublisher{}, nil)
name, battleTag := " New Name ", ""
out, err := service.UpdateOwnProfile(context.Background(),
domain.Account{ID: "account"},
domain.Player{ID: "player", AccountID: "account", DisplayName: "Old", BattleTag: "Old#1234"},
UpdateOwnProfileInput{
DisplayName: &name, BattleTag: &battleTag,
Ratings: domain.Ratings{Tank: 1, Damage: 20, Support: 40},
},
)
if err != nil {
t.Fatal(err)
}
if out.DisplayName != "New Name" || out.BattleTag != "" {
t.Fatalf("identity was not normalized and cleared: %+v", out)
}
if _, err = service.UpdateOwnProfile(context.Background(),
domain.Account{ID: "other"}, domain.Player{AccountID: "account"},
UpdateOwnProfileInput{},
); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("profile updates must remain self-only, got %v", err)
}
}
func validEvent(now time.Time) domain.Event {
return domain.Event{
Name: "Sunday Mix",
Description: "Community scrim",
StartsAt: now.Add(24 * time.Hour),
EndsAt: now.Add(28 * time.Hour),
}
}