feat: user service
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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.RaceName, stored.RaceName)
|
||||
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) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.RaceName == raceName {
|
||||
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) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return 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"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
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{}
|
||||
)
|
||||
Reference in New Issue
Block a user