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
+338
View File
@@ -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")
+207
View File
@@ -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)
})
}
}