feat: user service
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Package account defines the logical user-account entities owned directly by
|
||||
// User Service.
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// RaceNameCanonicalKey stores the policy-produced reservation key used to
|
||||
// enforce replaceable race-name uniqueness.
|
||||
type RaceNameCanonicalKey string
|
||||
|
||||
// String returns RaceNameCanonicalKey as its stored canonical string.
|
||||
func (key RaceNameCanonicalKey) String() string {
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// IsZero reports whether RaceNameCanonicalKey does not contain a usable value.
|
||||
func (key RaceNameCanonicalKey) IsZero() bool {
|
||||
return strings.TrimSpace(string(key)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether RaceNameCanonicalKey is non-empty and trimmed.
|
||||
func (key RaceNameCanonicalKey) Validate() error {
|
||||
switch {
|
||||
case key.IsZero():
|
||||
return fmt.Errorf("race name canonical key must not be empty")
|
||||
case strings.TrimSpace(string(key)) != string(key):
|
||||
return fmt.Errorf("race name canonical key must not contain surrounding whitespace")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UserAccount stores the current editable account state of one regular user.
|
||||
type UserAccount struct {
|
||||
// UserID identifies the durable regular-user account.
|
||||
UserID common.UserID
|
||||
|
||||
// Email stores the normalized login/contact address of the account.
|
||||
Email common.Email
|
||||
|
||||
// RaceName stores the original-casing user-facing race name.
|
||||
RaceName common.RaceName
|
||||
|
||||
// PreferredLanguage stores the current declared language tag.
|
||||
PreferredLanguage common.LanguageTag
|
||||
|
||||
// TimeZone stores the current declared time-zone name.
|
||||
TimeZone common.TimeZoneName
|
||||
|
||||
// DeclaredCountry stores the latest effective declared-country value. The
|
||||
// zero value means the geo workflow has not synchronized any country yet.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// CreatedAt stores the account creation timestamp.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores the last account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether UserAccount satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record UserAccount) Validate() error {
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("user account user id: %w", err)
|
||||
}
|
||||
if err := record.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("user account email: %w", err)
|
||||
}
|
||||
if err := record.RaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("user account race name: %w", err)
|
||||
}
|
||||
if err := record.PreferredLanguage.Validate(); err != nil {
|
||||
return fmt.Errorf("user account preferred language: %w", err)
|
||||
}
|
||||
if err := record.TimeZone.Validate(); err != nil {
|
||||
return fmt.Errorf("user account time zone: %w", err)
|
||||
}
|
||||
if !record.DeclaredCountry.IsZero() {
|
||||
if err := record.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("user account declared country: %w", err)
|
||||
}
|
||||
}
|
||||
if err := common.ValidateTimestamp("user account created at", record.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("user account updated at", record.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("user account updated at must not be before created at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RaceNameReservation stores the current uniqueness reservation for one
|
||||
// canonicalized race-name key.
|
||||
type RaceNameReservation struct {
|
||||
// CanonicalKey stores the policy-produced uniqueness key.
|
||||
CanonicalKey RaceNameCanonicalKey
|
||||
|
||||
// UserID identifies the account that owns the reservation.
|
||||
UserID common.UserID
|
||||
|
||||
// RaceName stores the original-casing name linked to the reservation.
|
||||
RaceName common.RaceName
|
||||
|
||||
// ReservedAt stores when the reservation was acquired.
|
||||
ReservedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether RaceNameReservation satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record RaceNameReservation) Validate() error {
|
||||
if err := record.CanonicalKey.Validate(); err != nil {
|
||||
return fmt.Errorf("race name reservation canonical key: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("race name reservation user id: %w", err)
|
||||
}
|
||||
if err := record.RaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("race name reservation race name: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("race name reservation reserved at", record.ReservedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserAccountValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
updatedAt := createdAt.Add(2 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record UserAccount
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid without declared country",
|
||||
record: 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/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid with declared country",
|
||||
record: 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/Berlin"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updated before created",
|
||||
record: 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/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(-time.Second),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaceNameReservationValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record RaceNameReservation
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
record: RaceNameReservation{
|
||||
CanonicalKey: RaceNameCanonicalKey("pilot-nova"),
|
||||
UserID: common.UserID("user-123"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty canonical key",
|
||||
record: RaceNameReservation{
|
||||
UserID: common.UserID("user-123"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user