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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package authblock defines the dedicated pre-user auth-block entity stored by
|
||||
// User Service.
|
||||
package authblock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// BlockedEmailSubject stores a blocked e-mail subject that may exist before
|
||||
// any user account exists.
|
||||
type BlockedEmailSubject struct {
|
||||
// Email stores the normalized blocked e-mail subject.
|
||||
Email common.Email
|
||||
|
||||
// ReasonCode stores the machine-readable reason for the block.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// BlockedAt stores when the block became effective.
|
||||
BlockedAt time.Time
|
||||
|
||||
// Actor stores optional audit metadata for the block initiator.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ResolvedUserID stores the linked user when the blocked e-mail already
|
||||
// belongs to an existing account.
|
||||
ResolvedUserID common.UserID
|
||||
}
|
||||
|
||||
// Validate reports whether BlockedEmailSubject satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record BlockedEmailSubject) Validate() error {
|
||||
if err := record.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject email: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("blocked email subject blocked at", record.BlockedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if !record.Actor.IsZero() {
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject actor: %w", err)
|
||||
}
|
||||
}
|
||||
if !record.ResolvedUserID.IsZero() {
|
||||
if err := record.ResolvedUserID.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject resolved user id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package authblock
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBlockedEmailSubjectValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record BlockedEmailSubject
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid without actor or user",
|
||||
record: BlockedEmailSubject{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid with actor and user",
|
||||
record: BlockedEmailSubject{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ResolvedUserID: common.UserID("user-123"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing blocked at",
|
||||
record: BlockedEmailSubject{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
},
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package entitlement defines the logical entitlement entities owned by User
|
||||
// Service.
|
||||
package entitlement
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// PlanCode identifies one supported entitlement plan.
|
||||
type PlanCode string
|
||||
|
||||
const (
|
||||
// PlanCodeFree reports the free default entitlement.
|
||||
PlanCodeFree PlanCode = "free"
|
||||
|
||||
// PlanCodePaidMonthly reports a finite monthly paid entitlement.
|
||||
PlanCodePaidMonthly PlanCode = "paid_monthly"
|
||||
|
||||
// PlanCodePaidYearly reports a finite yearly paid entitlement.
|
||||
PlanCodePaidYearly PlanCode = "paid_yearly"
|
||||
|
||||
// PlanCodePaidLifetime reports a non-expiring paid entitlement.
|
||||
PlanCodePaidLifetime PlanCode = "paid_lifetime"
|
||||
)
|
||||
|
||||
// IsKnown reports whether PlanCode belongs to the frozen v1 catalog.
|
||||
func (code PlanCode) IsKnown() bool {
|
||||
switch code {
|
||||
case PlanCodeFree, PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsPaid reports whether PlanCode represents a paid entitlement state.
|
||||
func (code PlanCode) IsPaid() bool {
|
||||
switch code {
|
||||
case PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HasFiniteExpiry reports whether PlanCode requires a bounded `ends_at`
|
||||
// value in the Stage 07 entitlement timeline model.
|
||||
func (code PlanCode) HasFiniteExpiry() bool {
|
||||
switch code {
|
||||
case PlanCodePaidMonthly, PlanCodePaidYearly:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementRecordID identifies one immutable entitlement history record.
|
||||
type EntitlementRecordID string
|
||||
|
||||
// String returns EntitlementRecordID as its stored identifier string.
|
||||
func (id EntitlementRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether EntitlementRecordID does not contain a usable value.
|
||||
func (id EntitlementRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether EntitlementRecordID is non-empty, normalized, and
|
||||
// uses the frozen Stage 02 prefix.
|
||||
func (id EntitlementRecordID) Validate() error {
|
||||
switch {
|
||||
case id.IsZero():
|
||||
return fmt.Errorf("entitlement record id must not be empty")
|
||||
case strings.TrimSpace(string(id)) != string(id):
|
||||
return fmt.Errorf("entitlement record id must not contain surrounding whitespace")
|
||||
case !strings.HasPrefix(string(id), "entitlement-"):
|
||||
return fmt.Errorf("entitlement record id must start with %q", "entitlement-")
|
||||
case len(string(id)) == len("entitlement-"):
|
||||
return fmt.Errorf("entitlement record id must contain opaque data after %q", "entitlement-")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PeriodRecord stores one entitlement-period history record.
|
||||
type PeriodRecord struct {
|
||||
// RecordID identifies the immutable history record.
|
||||
RecordID EntitlementRecordID
|
||||
|
||||
// UserID identifies the account that owns the entitlement record.
|
||||
UserID common.UserID
|
||||
|
||||
// PlanCode stores the effective plan for the recorded period.
|
||||
PlanCode PlanCode
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// Actor stores the audit actor metadata captured for the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ReasonCode stores the machine-readable reason for the mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// StartsAt stores when the period becomes effective.
|
||||
StartsAt time.Time
|
||||
|
||||
// EndsAt stores the optional planned end of the period.
|
||||
EndsAt *time.Time
|
||||
|
||||
// CreatedAt stores when the history record was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// ClosedAt stores when the period was later closed early by another trusted
|
||||
// mutation.
|
||||
ClosedAt *time.Time
|
||||
|
||||
// ClosedBy stores optional audit actor metadata for the close mutation.
|
||||
ClosedBy common.ActorRef
|
||||
|
||||
// ClosedReasonCode stores the reason for closing the period early.
|
||||
ClosedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether PeriodRecord satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record PeriodRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period user id: %w", err)
|
||||
}
|
||||
if !record.PlanCode.IsKnown() {
|
||||
return fmt.Errorf("entitlement period plan code %q is unsupported", record.PlanCode)
|
||||
}
|
||||
if err := record.Source.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period source: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period actor: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement period starts at", record.StartsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanBounds("entitlement period", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement period created at", record.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ClosedAt == nil {
|
||||
if !record.ClosedBy.IsZero() {
|
||||
return fmt.Errorf("entitlement period closed by must be empty when closed at is absent")
|
||||
}
|
||||
if !record.ClosedReasonCode.IsZero() {
|
||||
return fmt.Errorf("entitlement period closed reason code must be empty when closed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.ClosedAt.Before(record.StartsAt) {
|
||||
return fmt.Errorf("entitlement period closed at must not be before starts at")
|
||||
}
|
||||
if record.EndsAt != nil && record.ClosedAt.After(*record.EndsAt) {
|
||||
return fmt.Errorf("entitlement period closed at must not be after ends at")
|
||||
}
|
||||
if record.ClosedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("entitlement period closed at must not be before created at")
|
||||
}
|
||||
if err := record.ClosedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period closed by: %w", err)
|
||||
}
|
||||
if err := record.ClosedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period closed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEffectiveAt reports whether PeriodRecord is the currently effective
|
||||
// segment at the supplied timestamp.
|
||||
func (record PeriodRecord) IsEffectiveAt(now time.Time) bool {
|
||||
if record.ClosedAt != nil {
|
||||
return false
|
||||
}
|
||||
if record.StartsAt.After(now) {
|
||||
return false
|
||||
}
|
||||
if record.EndsAt != nil && !record.EndsAt.After(now) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CurrentSnapshot stores the read-optimized current entitlement state of one
|
||||
// user account.
|
||||
type CurrentSnapshot struct {
|
||||
// UserID identifies the account that owns the current entitlement.
|
||||
UserID common.UserID
|
||||
|
||||
// PlanCode stores the current effective plan code.
|
||||
PlanCode PlanCode
|
||||
|
||||
// IsPaid stores the materialized paid/free state used on hot read paths.
|
||||
IsPaid bool
|
||||
|
||||
// StartsAt stores when the current effective state started.
|
||||
StartsAt time.Time
|
||||
|
||||
// EndsAt stores the optional end of the current finite entitlement.
|
||||
EndsAt *time.Time
|
||||
|
||||
// Source stores the machine-readable source of the current state.
|
||||
Source common.Source
|
||||
|
||||
// Actor stores the actor metadata attached to the last successful mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ReasonCode stores the machine-readable reason attached to the last
|
||||
// successful mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// UpdatedAt stores when the snapshot was last recomputed.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether CurrentSnapshot satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record CurrentSnapshot) Validate() error {
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot user id: %w", err)
|
||||
}
|
||||
if !record.PlanCode.IsKnown() {
|
||||
return fmt.Errorf("entitlement snapshot plan code %q is unsupported", record.PlanCode)
|
||||
}
|
||||
if record.IsPaid != record.PlanCode.IsPaid() {
|
||||
return fmt.Errorf("entitlement snapshot paid flag must match plan code %q", record.PlanCode)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement snapshot starts at", record.StartsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanBounds("entitlement snapshot", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := record.Source.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot source: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot actor: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement snapshot updated at", record.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasFiniteExpiry reports whether CurrentSnapshot participates in the finite
|
||||
// paid-expiry index.
|
||||
func (record CurrentSnapshot) HasFiniteExpiry() bool {
|
||||
return record.IsPaid && record.EndsAt != nil
|
||||
}
|
||||
|
||||
// IsExpiredAt reports whether CurrentSnapshot represents a finite paid state
|
||||
// that has already reached its stored expiry.
|
||||
func (record CurrentSnapshot) IsExpiredAt(now time.Time) bool {
|
||||
return record.HasFiniteExpiry() && !record.EndsAt.After(now)
|
||||
}
|
||||
|
||||
// PaidState identifies the coarse free-versus-paid filter used by admin
|
||||
// listing.
|
||||
type PaidState string
|
||||
|
||||
const (
|
||||
// PaidStateFree filters accounts whose current entitlement is free.
|
||||
PaidStateFree PaidState = "free"
|
||||
|
||||
// PaidStatePaid filters accounts whose current entitlement is paid.
|
||||
PaidStatePaid PaidState = "paid"
|
||||
)
|
||||
|
||||
// IsKnown reports whether PaidState belongs to the frozen Stage 02 filter
|
||||
// vocabulary.
|
||||
func (state PaidState) IsKnown() bool {
|
||||
switch state {
|
||||
case "", PaidStateFree, PaidStatePaid:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validatePlanBounds(
|
||||
name string,
|
||||
planCode PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt *time.Time,
|
||||
) error {
|
||||
switch {
|
||||
case planCode.HasFiniteExpiry():
|
||||
if endsAt == nil {
|
||||
return fmt.Errorf("%s ends at must be present for plan code %q", name, planCode)
|
||||
}
|
||||
if !endsAt.After(startsAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
case endsAt != nil:
|
||||
return fmt.Errorf("%s ends at must be empty for plan code %q", name, planCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package entitlement
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPeriodRecordValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
createdAt := startsAt.Add(-time.Hour)
|
||||
closedAt := startsAt.Add(12 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record PeriodRecord
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid open record",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid closed record",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
CreatedAt: createdAt,
|
||||
ClosedAt: &closedAt,
|
||||
ClosedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
ClosedReasonCode: common.ReasonCode("manual_revoke"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close metadata without closed at",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
CreatedAt: createdAt,
|
||||
ClosedReasonCode: common.ReasonCode("manual_revoke"),
|
||||
},
|
||||
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 TestCurrentSnapshotValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
updatedAt := startsAt.Add(2 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record CurrentSnapshot
|
||||
wantErr bool
|
||||
wantFinite bool
|
||||
}{
|
||||
{
|
||||
name: "valid finite paid snapshot",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
wantFinite: true,
|
||||
},
|
||||
{
|
||||
name: "valid free snapshot",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: startsAt,
|
||||
Source: common.Source("system"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service")},
|
||||
ReasonCode: common.ReasonCode("default_free_plan"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paid flag mismatch",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodeFree,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
Source: common.Source("system"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service")},
|
||||
ReasonCode: common.ReasonCode("default_free_plan"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
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)
|
||||
require.Equal(t, tt.wantFinite, tt.record.HasFiniteExpiry())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
// Package policy defines sanction, limit, and eligibility-domain entities used
|
||||
// by User Service.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// SanctionCode identifies one supported sanction in the v1 policy catalog.
|
||||
type SanctionCode string
|
||||
|
||||
const (
|
||||
// SanctionCodeLoginBlock denies login.
|
||||
SanctionCodeLoginBlock SanctionCode = "login_block"
|
||||
|
||||
// SanctionCodePrivateGameCreateBlock denies private-game creation.
|
||||
SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block"
|
||||
|
||||
// SanctionCodePrivateGameManageBlock denies private-game management.
|
||||
SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block"
|
||||
|
||||
// SanctionCodeGameJoinBlock denies game joining.
|
||||
SanctionCodeGameJoinBlock SanctionCode = "game_join_block"
|
||||
|
||||
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
|
||||
// mutations.
|
||||
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
|
||||
)
|
||||
|
||||
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
|
||||
func (code SanctionCode) IsKnown() bool {
|
||||
switch code {
|
||||
case SanctionCodeLoginBlock,
|
||||
SanctionCodePrivateGameCreateBlock,
|
||||
SanctionCodePrivateGameManageBlock,
|
||||
SanctionCodeGameJoinBlock,
|
||||
SanctionCodeProfileUpdateBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LimitCode identifies one user-specific limit code recognized by User
|
||||
// Service.
|
||||
type LimitCode string
|
||||
|
||||
const (
|
||||
// LimitCodeMaxOwnedPrivateGames limits how many private games the user may
|
||||
// own while the current entitlement is paid.
|
||||
LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games"
|
||||
|
||||
// LimitCodeMaxPendingPublicApplications stores the total public-games budget
|
||||
// consumed together with current active public memberships when Game Lobby
|
||||
// derives remaining pending application headroom.
|
||||
LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications"
|
||||
|
||||
// LimitCodeMaxActiveGameMemberships limits how many active public-game
|
||||
// memberships the user may hold at once.
|
||||
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
|
||||
)
|
||||
|
||||
const (
|
||||
// LimitCodeMaxActivePrivateGames is a retired legacy code recognized only
|
||||
// so old stored records do not break current reads.
|
||||
LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games"
|
||||
|
||||
// LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code
|
||||
// recognized only so old stored records do not break current reads.
|
||||
LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests"
|
||||
|
||||
// LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code
|
||||
// recognized only so old stored records do not break current reads.
|
||||
LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent"
|
||||
)
|
||||
|
||||
// IsKnown reports whether LimitCode belongs to the current supported write/API
|
||||
// catalog.
|
||||
func (code LimitCode) IsKnown() bool {
|
||||
return code.IsSupported()
|
||||
}
|
||||
|
||||
// IsSupported reports whether LimitCode belongs to the current supported
|
||||
// write/API catalog.
|
||||
func (code LimitCode) IsSupported() bool {
|
||||
switch code {
|
||||
case LimitCodeMaxOwnedPrivateGames,
|
||||
LimitCodeMaxPendingPublicApplications,
|
||||
LimitCodeMaxActiveGameMemberships:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsRetired reports whether LimitCode is a retired legacy code recognized
|
||||
// only for read compatibility with already stored history records.
|
||||
func (code LimitCode) IsRetired() bool {
|
||||
switch code {
|
||||
case LimitCodeMaxActivePrivateGames,
|
||||
LimitCodeMaxPendingPrivateJoinRequests,
|
||||
LimitCodeMaxPendingPrivateInvitesSent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsRecognized reports whether LimitCode is either currently supported or
|
||||
// retired-but-recognized for read compatibility.
|
||||
func (code LimitCode) IsRecognized() bool {
|
||||
return code.IsSupported() || code.IsRetired()
|
||||
}
|
||||
|
||||
// EligibilityMarker identifies one derived eligibility boolean that may be
|
||||
// indexed for admin listing.
|
||||
type EligibilityMarker string
|
||||
|
||||
const (
|
||||
// EligibilityMarkerCanLogin tracks whether the user may currently log in.
|
||||
EligibilityMarkerCanLogin EligibilityMarker = "can_login"
|
||||
|
||||
// EligibilityMarkerCanCreatePrivateGame tracks whether the user may create
|
||||
// a private game.
|
||||
EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game"
|
||||
|
||||
// EligibilityMarkerCanManagePrivateGame tracks whether the user may manage
|
||||
// a private game.
|
||||
EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game"
|
||||
|
||||
// EligibilityMarkerCanJoinGame tracks whether the user may join a game.
|
||||
EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game"
|
||||
|
||||
// EligibilityMarkerCanUpdateProfile tracks whether the user may update
|
||||
// self-service profile/settings fields.
|
||||
EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile"
|
||||
)
|
||||
|
||||
// IsKnown reports whether EligibilityMarker belongs to the frozen v1 set.
|
||||
func (marker EligibilityMarker) IsKnown() bool {
|
||||
switch marker {
|
||||
case EligibilityMarkerCanLogin,
|
||||
EligibilityMarkerCanCreatePrivateGame,
|
||||
EligibilityMarkerCanManagePrivateGame,
|
||||
EligibilityMarkerCanJoinGame,
|
||||
EligibilityMarkerCanUpdateProfile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SanctionRecordID identifies one sanction history record.
|
||||
type SanctionRecordID string
|
||||
|
||||
// String returns SanctionRecordID as its stored identifier string.
|
||||
func (id SanctionRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether SanctionRecordID does not contain a usable value.
|
||||
func (id SanctionRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether SanctionRecordID is non-empty, normalized, and
|
||||
// uses the frozen Stage 02 prefix.
|
||||
func (id SanctionRecordID) Validate() error {
|
||||
return validatePrefixedRecordID("sanction record id", string(id), "sanction-")
|
||||
}
|
||||
|
||||
// LimitRecordID identifies one limit history record.
|
||||
type LimitRecordID string
|
||||
|
||||
// String returns LimitRecordID as its stored identifier string.
|
||||
func (id LimitRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether LimitRecordID does not contain a usable value.
|
||||
func (id LimitRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether LimitRecordID is non-empty, normalized, and uses
|
||||
// the frozen Stage 02 prefix.
|
||||
func (id LimitRecordID) Validate() error {
|
||||
return validatePrefixedRecordID("limit record id", string(id), "limit-")
|
||||
}
|
||||
|
||||
// SanctionRecord stores one sanction history record.
|
||||
type SanctionRecord struct {
|
||||
// RecordID identifies the sanction history record.
|
||||
RecordID SanctionRecordID
|
||||
|
||||
// UserID identifies the account that owns the sanction.
|
||||
UserID common.UserID
|
||||
|
||||
// SanctionCode stores the sanction applied to the account.
|
||||
SanctionCode SanctionCode
|
||||
|
||||
// Scope stores the machine-readable scope attached to the sanction.
|
||||
Scope common.Scope
|
||||
|
||||
// ReasonCode stores the reason for the sanction mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata for the apply mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the sanction becomes effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned expiry of the sanction.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores when the sanction was later removed explicitly.
|
||||
RemovedAt *time.Time
|
||||
|
||||
// RemovedBy stores the audit actor metadata for the remove mutation.
|
||||
RemovedBy common.ActorRef
|
||||
|
||||
// RemovedReasonCode stores the reason for the remove mutation.
|
||||
RemovedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether SanctionRecord satisfies the frozen structural
|
||||
// invariants that do not depend on a caller-supplied clock.
|
||||
func (record SanctionRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction user id: %w", err)
|
||||
}
|
||||
if !record.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode)
|
||||
}
|
||||
if err := record.Scope.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction scope: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction reason code: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if record.RemovedAt == nil {
|
||||
if !record.RemovedBy.IsZero() {
|
||||
return fmt.Errorf("sanction removed by must be empty when removed at is absent")
|
||||
}
|
||||
if !record.RemovedReasonCode.IsZero() {
|
||||
return fmt.Errorf("sanction removed reason code must be empty when removed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.RemovedAt.Before(record.AppliedAt) {
|
||||
return fmt.Errorf("sanction removed at must not be before applied at")
|
||||
}
|
||||
if err := record.RemovedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction removed by: %w", err)
|
||||
}
|
||||
if err := record.RemovedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction removed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAt reports whether SanctionRecord also satisfies the current-time
|
||||
// Stage 02 invariant that `applied_at` must not be in the future.
|
||||
func (record SanctionRecord) ValidateAt(now time.Time) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if now.IsZero() {
|
||||
return fmt.Errorf("sanction validation time must not be zero")
|
||||
}
|
||||
if record.AppliedAt.After(now.UTC()) {
|
||||
return fmt.Errorf("sanction applied at must not be in the future")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActiveAt reports whether SanctionRecord is active at now according to the
|
||||
// frozen Stage 02 rules.
|
||||
func (record SanctionRecord) IsActiveAt(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
switch {
|
||||
case now.IsZero():
|
||||
return false
|
||||
case record.AppliedAt.After(now):
|
||||
return false
|
||||
case record.RemovedAt != nil:
|
||||
return false
|
||||
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// LimitRecord stores one user-specific limit history record.
|
||||
type LimitRecord struct {
|
||||
// RecordID identifies the limit history record.
|
||||
RecordID LimitRecordID
|
||||
|
||||
// UserID identifies the account that owns the limit.
|
||||
UserID common.UserID
|
||||
|
||||
// LimitCode stores which count-based limit is overridden.
|
||||
LimitCode LimitCode
|
||||
|
||||
// Value stores the override value.
|
||||
Value int
|
||||
|
||||
// ReasonCode stores the reason for the limit mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata for the set mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the limit becomes effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned expiry of the limit.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores when the limit was later removed explicitly.
|
||||
RemovedAt *time.Time
|
||||
|
||||
// RemovedBy stores the audit actor metadata for the remove mutation.
|
||||
RemovedBy common.ActorRef
|
||||
|
||||
// RemovedReasonCode stores the reason for the remove mutation.
|
||||
RemovedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether LimitRecord satisfies the structural invariants
|
||||
// that do not depend on a caller-supplied clock. Retired legacy limit codes
|
||||
// remain recognized here so already stored records still decode safely.
|
||||
func (record LimitRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("limit record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("limit user id: %w", err)
|
||||
}
|
||||
if !record.LimitCode.IsRecognized() {
|
||||
return fmt.Errorf("limit code %q is unsupported", record.LimitCode)
|
||||
}
|
||||
if record.Value < 0 {
|
||||
return fmt.Errorf("limit value must not be negative")
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit reason code: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("limit actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if record.RemovedAt == nil {
|
||||
if !record.RemovedBy.IsZero() {
|
||||
return fmt.Errorf("limit removed by must be empty when removed at is absent")
|
||||
}
|
||||
if !record.RemovedReasonCode.IsZero() {
|
||||
return fmt.Errorf("limit removed reason code must be empty when removed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.RemovedAt.Before(record.AppliedAt) {
|
||||
return fmt.Errorf("limit removed at must not be before applied at")
|
||||
}
|
||||
if err := record.RemovedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("limit removed by: %w", err)
|
||||
}
|
||||
if err := record.RemovedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit removed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAt reports whether LimitRecord also satisfies the current-time Stage
|
||||
// 02 invariant that `applied_at` must not be in the future.
|
||||
func (record LimitRecord) ValidateAt(now time.Time) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if now.IsZero() {
|
||||
return fmt.Errorf("limit validation time must not be zero")
|
||||
}
|
||||
if record.AppliedAt.After(now.UTC()) {
|
||||
return fmt.Errorf("limit applied at must not be in the future")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActiveAt reports whether LimitRecord is active at now according to the
|
||||
// frozen Stage 02 rules.
|
||||
func (record LimitRecord) IsActiveAt(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
switch {
|
||||
case now.IsZero():
|
||||
return false
|
||||
case record.AppliedAt.After(now):
|
||||
return false
|
||||
case record.RemovedAt != nil:
|
||||
return false
|
||||
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveSanctionsAt returns the active sanctions at now, sorted
|
||||
// deterministically by `sanction_code`. The function returns an error when the
|
||||
// input contains structurally invalid records or more than one active sanction
|
||||
// for the same `user_id + sanction_code`.
|
||||
func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) {
|
||||
active := make([]SanctionRecord, 0, len(records))
|
||||
seen := make(map[SanctionCode]struct{}, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
if err := record.ValidateAt(now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !record.IsActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[record.SanctionCode]; ok {
|
||||
return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode)
|
||||
}
|
||||
seen[record.SanctionCode] = struct{}{}
|
||||
active = append(active, record)
|
||||
}
|
||||
|
||||
slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int {
|
||||
return strings.Compare(string(left.SanctionCode), string(right.SanctionCode))
|
||||
})
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// ActiveLimitsAt returns the active limits at now, sorted deterministically by
|
||||
// `limit_code`. Retired legacy limit codes are ignored so historical records
|
||||
// stored under the old catalog do not affect current effective reads. The
|
||||
// function returns an error when the input contains structurally invalid
|
||||
// records or more than one active current limit for the same
|
||||
// `user_id + limit_code`.
|
||||
func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) {
|
||||
active := make([]LimitRecord, 0, len(records))
|
||||
seen := make(map[LimitCode]struct{}, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
if err := record.ValidateAt(now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !record.IsActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
if !record.LimitCode.IsSupported() {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[record.LimitCode]; ok {
|
||||
return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode)
|
||||
}
|
||||
seen[record.LimitCode] = struct{}{}
|
||||
active = append(active, record)
|
||||
}
|
||||
|
||||
slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int {
|
||||
return strings.Compare(string(left.LimitCode), string(right.LimitCode))
|
||||
})
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func validatePrefixedRecordID(name string, value string, prefix 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)
|
||||
case !strings.HasPrefix(value, prefix):
|
||||
return fmt.Errorf("%s must start with %q", name, prefix)
|
||||
case len(value) == len(prefix):
|
||||
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSanctionRecordValidateAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
expiresAt := now.Add(time.Hour)
|
||||
removedAt := now.Add(30 * time.Minute)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record SanctionRecord
|
||||
wantErr bool
|
||||
wantActive bool
|
||||
}{
|
||||
{
|
||||
name: "active",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Minute),
|
||||
ExpiresAt: &expiresAt,
|
||||
},
|
||||
wantActive: true,
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: ptrTime(now.Add(-time.Minute)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removed",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
RemovedAt: &removedAt,
|
||||
RemovedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
RemovedReasonCode: common.ReasonCode("manual_remove"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "future applied at",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(time.Minute),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.ValidateAt(now)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantActive, tt.record.IsActiveAt(now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSanctionsAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
records := []SanctionRecord{
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeProfileUpdateBlock,
|
||||
Scope: common.Scope("profile"),
|
||||
ReasonCode: common.ReasonCode("moderation"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: ptrTime(now.Add(-time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
active, err := ActiveSanctionsAt(records, now)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, active, 1)
|
||||
require.Equal(t, SanctionCodeProfileUpdateBlock, active[0].SanctionCode)
|
||||
}
|
||||
|
||||
func TestActiveSanctionsAtDuplicateActiveCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
_, err := ActiveSanctionsAt([]SanctionRecord{
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
}, now)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLimitRecordValidateAtAndActiveLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
record := LimitRecord{
|
||||
RecordID: LimitRecordID("limit-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Minute),
|
||||
}
|
||||
require.NoError(t, record.ValidateAt(now))
|
||||
require.True(t, record.IsActiveAt(now))
|
||||
|
||||
active, err := ActiveLimitsAt([]LimitRecord{
|
||||
record,
|
||||
{
|
||||
RecordID: LimitRecordID("limit-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxActivePrivateGames,
|
||||
Value: 7,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
}, now)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, active, 1)
|
||||
require.Equal(t, LimitCodeMaxOwnedPrivateGames, active[0].LimitCode)
|
||||
}
|
||||
|
||||
func TestLimitCodeSupportAndRetiredRecognition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, LimitCodeMaxOwnedPrivateGames.IsSupported())
|
||||
require.True(t, LimitCodeMaxPendingPublicApplications.IsSupported())
|
||||
require.True(t, LimitCodeMaxActiveGameMemberships.IsSupported())
|
||||
|
||||
require.True(t, LimitCodeMaxActivePrivateGames.IsRetired())
|
||||
require.True(t, LimitCodeMaxPendingPrivateJoinRequests.IsRetired())
|
||||
require.True(t, LimitCodeMaxPendingPrivateInvitesSent.IsRetired())
|
||||
|
||||
require.True(t, LimitCodeMaxActivePrivateGames.IsRecognized())
|
||||
require.False(t, LimitCode("unknown_limit").IsRecognized())
|
||||
require.False(t, LimitCodeMaxActivePrivateGames.IsKnown())
|
||||
}
|
||||
|
||||
func TestActiveLimitsAtDuplicateActiveCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
_, err := ActiveLimitsAt([]LimitRecord{
|
||||
{
|
||||
RecordID: LimitRecordID("limit-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 2,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
{
|
||||
RecordID: LimitRecordID("limit-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 5,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
}, now)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
Reference in New Issue
Block a user