package geosync import ( "context" "errors" "testing" "time" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" "github.com/stretchr/testify/require" ) func TestSyncServiceExecuteUpdatesDeclaredCountryAndPublishesEvent(t *testing.T) { t.Parallel() createdAt := time.Unix(1_775_240_000, 0).UTC() updatedAt := createdAt.Add(5 * time.Minute) record := validAccountRecord(createdAt, createdAt) store := newFakeAccountStore(record) publisher := &recordingDeclaredCountryChangedPublisher{ publishHook: func(event ports.DeclaredCountryChangedEvent) error { stored, err := store.GetByUserID(context.Background(), record.UserID) require.NoError(t, err) require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry) require.Equal(t, updatedAt, stored.UpdatedAt) require.Equal(t, common.Source("geo_profile_service"), event.Source) return nil }, } service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher) require.NoError(t, err) result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{ UserID: record.UserID.String(), DeclaredCountry: "FR", }) require.NoError(t, err) require.Equal(t, record.UserID.String(), result.UserID) require.Equal(t, "FR", result.DeclaredCountry) require.Equal(t, updatedAt, result.UpdatedAt) require.Equal(t, 1, store.updateCalls) stored, err := store.GetByUserID(context.Background(), record.UserID) require.NoError(t, err) require.Equal(t, record.Email, stored.Email) require.Equal(t, record.UserName, stored.UserName) require.Equal(t, record.PreferredLanguage, stored.PreferredLanguage) require.Equal(t, record.TimeZone, stored.TimeZone) require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry) require.Equal(t, record.CreatedAt, stored.CreatedAt) require.Equal(t, updatedAt, stored.UpdatedAt) published := publisher.PublishedEvents() require.Len(t, published, 1) require.Equal(t, record.UserID, published[0].UserID) require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry) require.Equal(t, updatedAt, published[0].UpdatedAt) require.Equal(t, common.Source("geo_profile_service"), published[0].Source) } func TestSyncServiceExecuteSameCountryIsNoOp(t *testing.T) { t.Parallel() createdAt := time.Unix(1_775_240_000, 0).UTC() record := validAccountRecord(createdAt, createdAt.Add(5*time.Minute)) store := newFakeAccountStore(record) publisher := &recordingDeclaredCountryChangedPublisher{} service, err := NewSyncService(store, fixedClock{now: createdAt.Add(time.Hour)}, publisher) require.NoError(t, err) result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{ UserID: record.UserID.String(), DeclaredCountry: record.DeclaredCountry.String(), }) require.NoError(t, err) require.Equal(t, record.UserID.String(), result.UserID) require.Equal(t, record.DeclaredCountry.String(), result.DeclaredCountry) require.Equal(t, record.UpdatedAt, result.UpdatedAt) require.Zero(t, store.updateCalls) require.Empty(t, publisher.PublishedEvents()) } func TestSyncServiceExecuteRejectsInvalidDeclaredCountry(t *testing.T) { t.Parallel() service, err := NewSyncService( newFakeAccountStore(validAccountRecord(time.Unix(1_775_240_000, 0).UTC(), time.Unix(1_775_240_000, 0).UTC())), fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, &recordingDeclaredCountryChangedPublisher{}, ) require.NoError(t, err) tests := []struct { name string value string }{ {name: "alias country code", value: "UK"}, {name: "lowercase", value: "de"}, {name: "non-country region", value: "EU"}, {name: "wrong length", value: "DEU"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() _, err := service.Execute(context.Background(), SyncDeclaredCountryInput{ UserID: "user-123", DeclaredCountry: tt.value, }) require.Error(t, err) require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err)) require.EqualError(t, err, "declared_country must be a valid ISO 3166-1 alpha-2 country code") }) } } func TestSyncServiceExecuteUnknownUserReturnsNotFound(t *testing.T) { t.Parallel() service, err := NewSyncService( newFakeAccountStore(), fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, &recordingDeclaredCountryChangedPublisher{}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), SyncDeclaredCountryInput{ UserID: "user-missing", DeclaredCountry: "DE", }) require.Error(t, err) require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err)) } func TestSyncServiceExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) { t.Parallel() createdAt := time.Unix(1_775_240_000, 0).UTC() updatedAt := createdAt.Add(time.Minute) record := validAccountRecord(createdAt, createdAt) store := newFakeAccountStore(record) publisher := &recordingDeclaredCountryChangedPublisher{ err: errors.New("publisher unavailable"), } service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher) require.NoError(t, err) result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{ UserID: record.UserID.String(), DeclaredCountry: "FR", }) require.NoError(t, err) require.Equal(t, "FR", result.DeclaredCountry) require.Equal(t, updatedAt, result.UpdatedAt) stored, err := store.GetByUserID(context.Background(), record.UserID) require.NoError(t, err) require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry) require.Equal(t, updatedAt, stored.UpdatedAt) published := publisher.PublishedEvents() require.Len(t, published, 1) require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry) } type fakeAccountStore struct { records map[common.UserID]account.UserAccount updateCalls int updateErr error } func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore { byUserID := make(map[common.UserID]account.UserAccount, len(records)) for _, record := range records { byUserID[record.UserID] = record } return &fakeAccountStore{records: byUserID} } func (store *fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error { return nil } func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) { record, ok := store.records[userID] if !ok { return account.UserAccount{}, ports.ErrNotFound } return record, nil } func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) { for _, record := range store.records { if record.Email == email { return record, nil } } return account.UserAccount{}, ports.ErrNotFound } func (store *fakeAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) { for _, record := range store.records { if record.UserName == userName { return record, nil } } return account.UserAccount{}, ports.ErrNotFound } func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) { _, ok := store.records[userID] return ok, nil } func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error { store.updateCalls++ if store.updateErr != nil { return store.updateErr } if _, ok := store.records[record.UserID]; !ok { return ports.ErrNotFound } store.records[record.UserID] = record return nil } type recordingDeclaredCountryChangedPublisher struct { err error publishHook func(event ports.DeclaredCountryChangedEvent) error published []ports.DeclaredCountryChangedEvent } func (publisher *recordingDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged( _ context.Context, event ports.DeclaredCountryChangedEvent, ) error { if err := event.Validate(); err != nil { return err } publisher.published = append(publisher.published, event) if publisher.publishHook != nil { if err := publisher.publishHook(event); err != nil { return err } } return publisher.err } func (publisher *recordingDeclaredCountryChangedPublisher) PublishedEvents() []ports.DeclaredCountryChangedEvent { events := make([]ports.DeclaredCountryChangedEvent, len(publisher.published)) copy(events, publisher.published) return events } type fixedClock struct { now time.Time } func (clock fixedClock) Now() time.Time { return clock.now } func validAccountRecord(createdAt time.Time, updatedAt time.Time) account.UserAccount { return account.UserAccount{ UserID: common.UserID("user-123"), Email: common.Email("pilot@example.com"), UserName: common.UserName("player-abcdefgh"), PreferredLanguage: common.LanguageTag("en"), TimeZone: common.TimeZoneName("Europe/Kaliningrad"), DeclaredCountry: common.CountryCode("DE"), CreatedAt: createdAt, UpdatedAt: updatedAt, } } var ( _ ports.UserAccountStore = (*fakeAccountStore)(nil) _ ports.DeclaredCountryChangedPublisher = (*recordingDeclaredCountryChangedPublisher)(nil) _ ports.Clock = fixedClock{} )