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), } }