feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
+194
View File
@@ -0,0 +1,194 @@
// Package geosync implements the trusted geo-facing declared-country sync
// command owned by User Service.
package geosync
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"golang.org/x/text/language"
)
const geoProfileServiceSource = common.Source("geo_profile_service")
// SyncDeclaredCountryInput stores one trusted geo-facing country-sync request.
type SyncDeclaredCountryInput struct {
// UserID identifies the regular user whose current declared country must be
// synchronized.
UserID string
// DeclaredCountry stores the new current effective declared country.
DeclaredCountry string
}
// SyncDeclaredCountryResult stores one trusted geo-facing country-sync result.
type SyncDeclaredCountryResult struct {
// UserID identifies the synchronized user.
UserID string `json:"user_id"`
// DeclaredCountry stores the current effective declared country after the
// command completes.
DeclaredCountry string `json:"declared_country"`
// UpdatedAt stores the effective account mutation timestamp. Same-value
// no-op syncs return the current stored timestamp unchanged.
UpdatedAt time.Time `json:"updated_at"`
}
// SyncService executes the trusted geo-facing declared-country sync command.
type SyncService struct {
accounts ports.UserAccountStore
clock ports.Clock
publisher ports.DeclaredCountryChangedPublisher
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewSyncService constructs one trusted declared-country sync command.
func NewSyncService(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
) (*SyncService, error) {
return NewSyncServiceWithObservability(accounts, clock, publisher, nil, nil)
}
// NewSyncServiceWithObservability constructs one trusted declared-country sync
// command with optional structured logging and event-publication metrics.
func NewSyncServiceWithObservability(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*SyncService, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("geo declared-country sync service: user account store must not be nil")
case clock == nil:
return nil, fmt.Errorf("geo declared-country sync service: clock must not be nil")
case publisher == nil:
return nil, fmt.Errorf("geo declared-country sync service: declared-country changed publisher must not be nil")
default:
return &SyncService{
accounts: accounts,
clock: clock,
publisher: publisher,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
}
// Execute synchronizes the current effective declared country of one user.
func (service *SyncService) Execute(
ctx context.Context,
input SyncDeclaredCountryInput,
) (result SyncDeclaredCountryResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "declared-country sync completed", err,
"use_case", "sync_declared_country",
"outcome", outcome,
"user_id", userIDString,
"source", geoProfileServiceSource.String(),
)
}()
if ctx == nil {
return SyncDeclaredCountryResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
userIDString = userID.String()
declaredCountry, err := parseDeclaredCountry(input.DeclaredCountry)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
record, err := service.accounts.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
if record.DeclaredCountry == declaredCountry {
outcome = "noop"
return resultFromAccount(record), nil
}
record.DeclaredCountry = declaredCountry
record.UpdatedAt = service.clock.Now().UTC()
if err := service.accounts.Update(ctx, record); err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
}
result = resultFromAccount(record)
outcome = "updated"
if err := service.publisher.PublishDeclaredCountryChanged(ctx, ports.DeclaredCountryChangedEvent{
UserID: record.UserID,
DeclaredCountry: record.DeclaredCountry,
UpdatedAt: record.UpdatedAt,
Source: geoProfileServiceSource,
}); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.DeclaredCountryChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.DeclaredCountryChangedEventType, err,
"use_case", "sync_declared_country",
"user_id", record.UserID.String(),
"source", geoProfileServiceSource.String(),
)
}
return result, nil
}
func parseDeclaredCountry(value string) (common.CountryCode, error) {
const message = "declared_country must be a valid ISO 3166-1 alpha-2 country code"
code := common.CountryCode(shared.NormalizeString(value))
if err := code.Validate(); err != nil {
return "", shared.InvalidRequest(message)
}
region, err := language.ParseRegion(code.String())
if err != nil || !region.IsCountry() || region.Canonicalize().String() != code.String() {
return "", shared.InvalidRequest(message)
}
return code, nil
}
func resultFromAccount(record account.UserAccount) SyncDeclaredCountryResult {
return SyncDeclaredCountryResult{
UserID: record.UserID.String(),
DeclaredCountry: record.DeclaredCountry.String(),
UpdatedAt: record.UpdatedAt.UTC(),
}
}
@@ -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{}
)