feat: user service
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
// Package common defines shared value objects used across the user-service
|
||||
// domain model.
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRaceNameLength = 64
|
||||
maxLanguageTagLength = 32
|
||||
maxTimeZoneNameLength = 128
|
||||
)
|
||||
|
||||
// UserID identifies one regular-platform user owned by User Service.
|
||||
type UserID string
|
||||
|
||||
// String returns UserID as its stored identifier string.
|
||||
func (id UserID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether UserID does not contain a usable identifier.
|
||||
func (id UserID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether UserID is non-empty, normalized, and uses the
|
||||
// frozen Stage 02 prefix.
|
||||
func (id UserID) Validate() error {
|
||||
return validatePrefixedToken("user id", string(id), "user-")
|
||||
}
|
||||
|
||||
// Email stores one normalized user-login e-mail address.
|
||||
type Email string
|
||||
|
||||
// String returns Email as its stored canonical string.
|
||||
func (email Email) String() string {
|
||||
return string(email)
|
||||
}
|
||||
|
||||
// IsZero reports whether Email does not contain a usable address.
|
||||
func (email Email) IsZero() bool {
|
||||
return strings.TrimSpace(string(email)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Email is non-empty, trimmed, and matches the same
|
||||
// single-address syntax expected by internal REST contracts.
|
||||
func (email Email) Validate() error {
|
||||
raw := string(email)
|
||||
if err := validateToken("email", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAddress, err := mail.ParseAddress(raw)
|
||||
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
|
||||
return fmt.Errorf("email %q must be a single valid email address", raw)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RaceName stores one original-casing race name selected for the user
|
||||
// account.
|
||||
type RaceName string
|
||||
|
||||
// String returns RaceName as its stored value.
|
||||
func (name RaceName) String() string {
|
||||
return string(name)
|
||||
}
|
||||
|
||||
// IsZero reports whether RaceName does not contain a usable value.
|
||||
func (name RaceName) IsZero() bool {
|
||||
return strings.TrimSpace(string(name)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether RaceName is non-empty, trimmed, and within the
|
||||
// frozen OpenAPI length bound.
|
||||
func (name RaceName) Validate() error {
|
||||
raw := string(name)
|
||||
if err := validateToken("race name", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > maxRaceNameLength {
|
||||
return fmt.Errorf("race name must be at most %d bytes", maxRaceNameLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LanguageTag stores one declared BCP 47 language-tag string.
|
||||
type LanguageTag string
|
||||
|
||||
// String returns LanguageTag as its stored value.
|
||||
func (tag LanguageTag) String() string {
|
||||
return string(tag)
|
||||
}
|
||||
|
||||
// IsZero reports whether LanguageTag does not contain a usable value.
|
||||
func (tag LanguageTag) IsZero() bool {
|
||||
return strings.TrimSpace(string(tag)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether LanguageTag is non-empty, trimmed, and within the
|
||||
// frozen OpenAPI length bound. Stage 02 intentionally freezes the storage
|
||||
// shape and not the later boundary-level BCP 47 parser choice.
|
||||
func (tag LanguageTag) Validate() error {
|
||||
raw := string(tag)
|
||||
if err := validateToken("language tag", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > maxLanguageTagLength {
|
||||
return fmt.Errorf("language tag must be at most %d bytes", maxLanguageTagLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimeZoneName stores one declared IANA time-zone name.
|
||||
type TimeZoneName string
|
||||
|
||||
// String returns TimeZoneName as its stored value.
|
||||
func (name TimeZoneName) String() string {
|
||||
return string(name)
|
||||
}
|
||||
|
||||
// IsZero reports whether TimeZoneName does not contain a usable value.
|
||||
func (name TimeZoneName) IsZero() bool {
|
||||
return strings.TrimSpace(string(name)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether TimeZoneName is non-empty, trimmed, and within the
|
||||
// frozen OpenAPI length bound. Later application stages may tighten
|
||||
// boundary-level validation further.
|
||||
func (name TimeZoneName) Validate() error {
|
||||
raw := string(name)
|
||||
if err := validateToken("time zone name", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > maxTimeZoneNameLength {
|
||||
return fmt.Errorf("time zone name must be at most %d bytes", maxTimeZoneNameLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountryCode stores one ISO 3166-1 alpha-2 code.
|
||||
type CountryCode string
|
||||
|
||||
// String returns CountryCode as its stored value.
|
||||
func (code CountryCode) String() string {
|
||||
return string(code)
|
||||
}
|
||||
|
||||
// IsZero reports whether CountryCode does not contain a usable value.
|
||||
func (code CountryCode) IsZero() bool {
|
||||
return strings.TrimSpace(string(code)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether CountryCode is an uppercase ISO 3166-1 alpha-2
|
||||
// code.
|
||||
func (code CountryCode) Validate() error {
|
||||
raw := string(code)
|
||||
if len(raw) != 2 {
|
||||
return fmt.Errorf("country code %q must contain exactly two letters", raw)
|
||||
}
|
||||
for idx := 0; idx < len(raw); idx++ {
|
||||
if raw[idx] < 'A' || raw[idx] > 'Z' {
|
||||
return fmt.Errorf("country code %q must contain only uppercase ASCII letters", raw)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActorType stores one machine-readable actor type for audit metadata.
|
||||
type ActorType string
|
||||
|
||||
// String returns ActorType as its stored value.
|
||||
func (actorType ActorType) String() string {
|
||||
return string(actorType)
|
||||
}
|
||||
|
||||
// IsZero reports whether ActorType does not contain a usable value.
|
||||
func (actorType ActorType) IsZero() bool {
|
||||
return strings.TrimSpace(string(actorType)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether ActorType is non-empty and trimmed.
|
||||
func (actorType ActorType) Validate() error {
|
||||
return validateToken("actor type", string(actorType))
|
||||
}
|
||||
|
||||
// ActorID stores one optional stable actor identifier.
|
||||
type ActorID string
|
||||
|
||||
// String returns ActorID as its stored value.
|
||||
func (actorID ActorID) String() string {
|
||||
return string(actorID)
|
||||
}
|
||||
|
||||
// IsZero reports whether ActorID does not contain a usable value.
|
||||
func (actorID ActorID) IsZero() bool {
|
||||
return strings.TrimSpace(string(actorID)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether ActorID is trimmed when present.
|
||||
func (actorID ActorID) Validate() error {
|
||||
if actorID.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateToken("actor id", string(actorID))
|
||||
}
|
||||
|
||||
// ActorRef stores actor metadata captured on trusted mutations.
|
||||
type ActorRef struct {
|
||||
// Type identifies the machine-readable actor class such as `admin`,
|
||||
// `service`, or `billing`.
|
||||
Type ActorType
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID ActorID
|
||||
}
|
||||
|
||||
// IsZero reports whether ActorRef does not contain any audit actor metadata.
|
||||
func (ref ActorRef) IsZero() bool {
|
||||
return ref.Type.IsZero() && ref.ID.IsZero()
|
||||
}
|
||||
|
||||
// Validate reports whether ActorRef contains a required type and an optional
|
||||
// trimmed identifier.
|
||||
func (ref ActorRef) Validate() error {
|
||||
if err := ref.Type.Validate(); err != nil {
|
||||
return fmt.Errorf("actor ref type: %w", err)
|
||||
}
|
||||
if err := ref.ID.Validate(); err != nil {
|
||||
return fmt.Errorf("actor ref id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReasonCode stores one machine-readable reason code.
|
||||
type ReasonCode string
|
||||
|
||||
// String returns ReasonCode as its stored value.
|
||||
func (code ReasonCode) String() string {
|
||||
return string(code)
|
||||
}
|
||||
|
||||
// IsZero reports whether ReasonCode does not contain a usable value.
|
||||
func (code ReasonCode) IsZero() bool {
|
||||
return strings.TrimSpace(string(code)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether ReasonCode is non-empty and trimmed.
|
||||
func (code ReasonCode) Validate() error {
|
||||
return validateToken("reason code", string(code))
|
||||
}
|
||||
|
||||
// Source stores one machine-readable mutation source.
|
||||
type Source string
|
||||
|
||||
// String returns Source as its stored value.
|
||||
func (source Source) String() string {
|
||||
return string(source)
|
||||
}
|
||||
|
||||
// IsZero reports whether Source does not contain a usable value.
|
||||
func (source Source) IsZero() bool {
|
||||
return strings.TrimSpace(string(source)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Source is non-empty and trimmed.
|
||||
func (source Source) Validate() error {
|
||||
return validateToken("source", string(source))
|
||||
}
|
||||
|
||||
// Scope stores one machine-readable sanction scope.
|
||||
type Scope string
|
||||
|
||||
// String returns Scope as its stored value.
|
||||
func (scope Scope) String() string {
|
||||
return string(scope)
|
||||
}
|
||||
|
||||
// IsZero reports whether Scope does not contain a usable value.
|
||||
func (scope Scope) IsZero() bool {
|
||||
return strings.TrimSpace(string(scope)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Scope is non-empty and trimmed.
|
||||
func (scope Scope) Validate() error {
|
||||
return validateToken("scope", string(scope))
|
||||
}
|
||||
|
||||
// ValidateTimestamp reports whether value is set.
|
||||
func ValidateTimestamp(name string, value time.Time) error {
|
||||
if value.IsZero() {
|
||||
return fmt.Errorf("%s must not be zero", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validatePrefixedToken(name string, value string, prefix string) error {
|
||||
if err := validateToken(name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return fmt.Errorf("%s must start with %q", name, prefix)
|
||||
}
|
||||
if len(value) == len(prefix) {
|
||||
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrInvertedTimeRange reports that the logical end of a range is not after
|
||||
// its start.
|
||||
var ErrInvertedTimeRange = errors.New("time range end must be after start")
|
||||
@@ -0,0 +1,207 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserIDValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value UserID
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: UserID("user-abc123")},
|
||||
{name: "empty", value: UserID(""), wantErr: true},
|
||||
{name: "surrounding whitespace", value: UserID(" user-abc123 "), wantErr: true},
|
||||
{name: "wrong prefix", value: UserID("account-abc123"), wantErr: true},
|
||||
{name: "prefix only", value: UserID("user-"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Email
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: Email("pilot@example.com")},
|
||||
{name: "empty", value: Email(""), wantErr: true},
|
||||
{name: "display name", value: Email("Pilot <pilot@example.com>"), wantErr: true},
|
||||
{name: "invalid", value: Email("not-an-email"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaceNameValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value RaceName
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: RaceName("Admiral Nova")},
|
||||
{name: "empty", value: RaceName(""), wantErr: true},
|
||||
{name: "too long", value: RaceName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageTagValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value LanguageTag
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: LanguageTag("en-US")},
|
||||
{name: "empty", value: LanguageTag(""), wantErr: true},
|
||||
{name: "surrounding whitespace", value: LanguageTag(" en "), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeZoneNameValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value TimeZoneName
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: TimeZoneName("Europe/Berlin")},
|
||||
{name: "empty", value: TimeZoneName(""), wantErr: true},
|
||||
{name: "surrounding whitespace", value: TimeZoneName(" UTC "), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryCodeValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value CountryCode
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: CountryCode("DE")},
|
||||
{name: "lowercase", value: CountryCode("de"), wantErr: true},
|
||||
{name: "wrong length", value: CountryCode("DEU"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActorRefValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value ActorRef
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid without id", value: ActorRef{Type: ActorType("service")}},
|
||||
{name: "valid with id", value: ActorRef{Type: ActorType("admin"), ID: ActorID("admin-1")}},
|
||||
{name: "missing type", value: ActorRef{ID: ActorID("admin-1")}, wantErr: true},
|
||||
{name: "invalid id whitespace", value: ActorRef{Type: ActorType("admin"), ID: ActorID(" admin-1 ")}, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user