feat: user service
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
// Package local provides small in-process runtime adapters used by the user
|
||||
// service process.
|
||||
package local
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock returns the current wall-clock time.
|
||||
type Clock struct{}
|
||||
|
||||
// Now returns the current time.
|
||||
func (Clock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/ports"
|
||||
)
|
||||
|
||||
// NoopDeclaredCountryChangedPublisher validates and discards auxiliary
|
||||
// declared-country change events.
|
||||
type NoopDeclaredCountryChangedPublisher struct{}
|
||||
|
||||
// PublishDeclaredCountryChanged validates event and discards it.
|
||||
func (NoopDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
|
||||
ctx context.Context,
|
||||
event ports.DeclaredCountryChangedEvent,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("publish declared-country changed event: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return event.Validate()
|
||||
}
|
||||
|
||||
var _ ports.DeclaredCountryChangedPublisher = NoopDeclaredCountryChangedPublisher{}
|
||||
@@ -0,0 +1,62 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/ports"
|
||||
)
|
||||
|
||||
// NoopDomainEventPublisher validates and discards auxiliary user-domain
|
||||
// events.
|
||||
type NoopDomainEventPublisher struct{}
|
||||
|
||||
// PublishProfileChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish profile changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishSettingsChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish settings changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishEntitlementChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish entitlement changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishSanctionChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish sanction changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishLimitChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish limit changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishDeclaredCountryChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish declared-country changed event", event.Validate)
|
||||
}
|
||||
|
||||
func validateNoopPublish(ctx context.Context, operation string, validate func() error) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validate()
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.ProfileChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.SettingsChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.EntitlementChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.SanctionChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.LimitChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.DeclaredCountryChangedPublisher = NoopDomainEventPublisher{}
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// IDGenerator creates opaque stable user identifiers and generated initial
|
||||
// race names.
|
||||
type IDGenerator struct{}
|
||||
|
||||
// NewUserID returns one newly generated opaque user identifier.
|
||||
func (IDGenerator) NewUserID() (common.UserID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate user id: %w", err)
|
||||
}
|
||||
|
||||
userID := common.UserID("user-" + token)
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate user id: %w", err)
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
|
||||
// form.
|
||||
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
token, err := randomToken(5)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate initial race name: %w", err)
|
||||
}
|
||||
|
||||
raceName := common.RaceName("player-" + token)
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate initial race name: %w", err)
|
||||
}
|
||||
|
||||
return raceName, nil
|
||||
}
|
||||
|
||||
// NewEntitlementRecordID returns one generated entitlement history record
|
||||
// identifier.
|
||||
func (IDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate entitlement record id: %w", err)
|
||||
}
|
||||
|
||||
recordID := entitlement.EntitlementRecordID("entitlement-" + token)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate entitlement record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
// NewSanctionRecordID returns one generated sanction history record
|
||||
// identifier.
|
||||
func (IDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate sanction record id: %w", err)
|
||||
}
|
||||
|
||||
recordID := policy.SanctionRecordID("sanction-" + token)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate sanction record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
// NewLimitRecordID returns one generated limit history record identifier.
|
||||
func (IDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate limit record id: %w", err)
|
||||
}
|
||||
|
||||
recordID := policy.LimitRecordID("limit-" + token)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate limit record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
buffer := make([]byte, size)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
confusables "github.com/disciplinedware/go-confusables"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
type confusableSkeletoner interface {
|
||||
Skeleton(string) string
|
||||
}
|
||||
|
||||
type raceNamePolicy struct {
|
||||
caseFolder cases.Caser
|
||||
skeletoner confusableSkeletoner
|
||||
}
|
||||
|
||||
var raceNameAntiFraudReplacer = strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
)
|
||||
|
||||
// NewRaceNamePolicy returns the local Stage 06 race-name canonicalization
|
||||
// policy backed by Unicode case folding, explicit ASCII anti-fraud mappings,
|
||||
// and a TR39 confusable skeleton.
|
||||
func NewRaceNamePolicy() (ports.RaceNamePolicy, error) {
|
||||
policy := &raceNamePolicy{
|
||||
caseFolder: cases.Fold(),
|
||||
skeletoner: confusables.Default(),
|
||||
}
|
||||
if policy.skeletoner == nil {
|
||||
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// CanonicalKey returns the stable uniqueness key for raceName.
|
||||
func (policy *raceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
switch {
|
||||
case policy == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil policy")
|
||||
case policy.skeletoner == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
|
||||
}
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
folded := policy.caseFolder.String(raceName.String())
|
||||
antiFraudMapped := raceNameAntiFraudReplacer.Replace(folded)
|
||||
key := account.RaceNameCanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRaceNamePolicyCanonicalKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewRaceNamePolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
left common.RaceName
|
||||
right common.RaceName
|
||||
}{
|
||||
{
|
||||
name: "case insensitive collision",
|
||||
left: common.RaceName("Pilot Nova"),
|
||||
right: common.RaceName("pilot nova"),
|
||||
},
|
||||
{
|
||||
name: "ascii anti fraud collision",
|
||||
left: common.RaceName("Pilot Nova"),
|
||||
right: common.RaceName("P1lot N0va"),
|
||||
},
|
||||
{
|
||||
name: "unicode confusable collision",
|
||||
left: common.RaceName("paypal"),
|
||||
right: common.RaceName("раураl"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
leftKey, err := policy.CanonicalKey(tt.left)
|
||||
require.NoError(t, err)
|
||||
rightKey, err := policy.CanonicalKey(tt.right)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rightKey, leftKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaceNameReservationPreservesOriginalDisplayValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewRaceNamePolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := shared.BuildRaceNameReservation(
|
||||
policy,
|
||||
common.UserID("user-123"),
|
||||
common.RaceName("P1lot Nova"),
|
||||
time.Unix(1_775_240_000, 0).UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.RaceName("P1lot Nova"), record.RaceName)
|
||||
require.NotEqual(t, account.RaceNameCanonicalKey(""), record.CanonicalKey)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package domainevents implements Redis Stream-backed auxiliary user-domain
|
||||
// event publishers.
|
||||
package domainevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Config configures one Redis-backed user domain-event publisher.
|
||||
type Config struct {
|
||||
// Addr is the Redis network address in host:port form.
|
||||
Addr string
|
||||
|
||||
// Username is the optional Redis ACL username.
|
||||
Username string
|
||||
|
||||
// Password is the optional Redis ACL password.
|
||||
Password string
|
||||
|
||||
// DB is the Redis logical database index.
|
||||
DB int
|
||||
|
||||
// TLSEnabled enables TLS with a conservative minimum protocol version.
|
||||
TLSEnabled bool
|
||||
|
||||
// Stream identifies the Redis Stream key used for domain events.
|
||||
Stream string
|
||||
|
||||
// StreamMaxLen bounds the stream with approximate trimming via
|
||||
// `XADD MAXLEN ~`.
|
||||
StreamMaxLen int64
|
||||
|
||||
// OperationTimeout bounds each Redis round trip performed by the adapter.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Publisher publishes auxiliary user-domain events into one Redis Stream.
|
||||
type Publisher struct {
|
||||
client *redis.Client
|
||||
stream string
|
||||
streamMaxLen int64
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs a Redis-backed domain-event publisher from cfg.
|
||||
func New(cfg Config) (*Publisher, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return nil, errors.New("new redis domain-event publisher: redis addr must not be empty")
|
||||
case cfg.DB < 0:
|
||||
return nil, errors.New("new redis domain-event publisher: redis db must not be negative")
|
||||
case strings.TrimSpace(cfg.Stream) == "":
|
||||
return nil, errors.New("new redis domain-event publisher: stream must not be empty")
|
||||
case cfg.StreamMaxLen <= 0:
|
||||
return nil, errors.New("new redis domain-event publisher: stream max len must be positive")
|
||||
case cfg.OperationTimeout <= 0:
|
||||
return nil, errors.New("new redis domain-event publisher: operation timeout must be positive")
|
||||
}
|
||||
|
||||
options := &redis.Options{
|
||||
Addr: cfg.Addr,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
Protocol: 2,
|
||||
DisableIdentity: true,
|
||||
}
|
||||
if cfg.TLSEnabled {
|
||||
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
return &Publisher{
|
||||
client: redis.NewClient(options),
|
||||
stream: cfg.Stream,
|
||||
streamMaxLen: cfg.StreamMaxLen,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying Redis client resources.
|
||||
func (publisher *Publisher) Close() error {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return publisher.client.Close()
|
||||
}
|
||||
|
||||
// Ping verifies that the configured Redis backend is reachable within the
|
||||
// adapter operation timeout budget.
|
||||
func (publisher *Publisher) Ping(ctx context.Context) error {
|
||||
operationCtx, cancel, err := publisher.operationContext(ctx, "ping redis domain-event publisher")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := publisher.client.Ping(operationCtx).Err(); err != nil {
|
||||
return fmt.Errorf("ping redis domain-event publisher: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishProfileChanged publishes one committed profile-change event.
|
||||
func (publisher *Publisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish profile changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.ProfileChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["race_name"] = event.RaceName.String()
|
||||
|
||||
return publisher.publish(ctx, "publish profile changed event", values)
|
||||
}
|
||||
|
||||
// PublishSettingsChanged publishes one committed settings-change event.
|
||||
func (publisher *Publisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish settings changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.SettingsChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["preferred_language"] = event.PreferredLanguage.String()
|
||||
values["time_zone"] = event.TimeZone.String()
|
||||
|
||||
return publisher.publish(ctx, "publish settings changed event", values)
|
||||
}
|
||||
|
||||
// PublishEntitlementChanged publishes one committed entitlement-change event.
|
||||
func (publisher *Publisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish entitlement changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.EntitlementChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["plan_code"] = string(event.PlanCode)
|
||||
values["is_paid"] = strconv.FormatBool(event.IsPaid)
|
||||
values["starts_at_ms"] = strconv.FormatInt(event.StartsAt.UTC().UnixMilli(), 10)
|
||||
values["reason_code"] = event.ReasonCode.String()
|
||||
values["actor_type"] = event.Actor.Type.String()
|
||||
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
|
||||
if !event.Actor.ID.IsZero() {
|
||||
values["actor_id"] = event.Actor.ID.String()
|
||||
}
|
||||
if event.EndsAt != nil {
|
||||
values["ends_at_ms"] = strconv.FormatInt(event.EndsAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
return publisher.publish(ctx, "publish entitlement changed event", values)
|
||||
}
|
||||
|
||||
// PublishSanctionChanged publishes one committed sanction-change event.
|
||||
func (publisher *Publisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish sanction changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.SanctionChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["sanction_code"] = string(event.SanctionCode)
|
||||
values["scope"] = event.Scope.String()
|
||||
values["reason_code"] = event.ReasonCode.String()
|
||||
values["actor_type"] = event.Actor.Type.String()
|
||||
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
|
||||
if !event.Actor.ID.IsZero() {
|
||||
values["actor_id"] = event.Actor.ID.String()
|
||||
}
|
||||
if event.ExpiresAt != nil {
|
||||
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
if event.RemovedAt != nil {
|
||||
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
return publisher.publish(ctx, "publish sanction changed event", values)
|
||||
}
|
||||
|
||||
// PublishLimitChanged publishes one committed limit-change event.
|
||||
func (publisher *Publisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish limit changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.LimitChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["limit_code"] = string(event.LimitCode)
|
||||
values["reason_code"] = event.ReasonCode.String()
|
||||
values["actor_type"] = event.Actor.Type.String()
|
||||
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
|
||||
if event.Value != nil {
|
||||
values["value"] = strconv.Itoa(*event.Value)
|
||||
}
|
||||
if !event.Actor.ID.IsZero() {
|
||||
values["actor_id"] = event.Actor.ID.String()
|
||||
}
|
||||
if event.ExpiresAt != nil {
|
||||
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
if event.RemovedAt != nil {
|
||||
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
return publisher.publish(ctx, "publish limit changed event", values)
|
||||
}
|
||||
|
||||
// PublishDeclaredCountryChanged publishes one committed declared-country change
|
||||
// event.
|
||||
func (publisher *Publisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish declared-country changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(
|
||||
ports.DeclaredCountryChangedEventType,
|
||||
event.UserID.String(),
|
||||
event.UpdatedAt,
|
||||
event.Source.String(),
|
||||
traceIDFromContext(ctx, event.TraceID),
|
||||
)
|
||||
values["declared_country"] = event.DeclaredCountry.String()
|
||||
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
|
||||
|
||||
return publisher.publish(ctx, "publish declared-country changed event", values)
|
||||
}
|
||||
|
||||
func (publisher *Publisher) publish(ctx context.Context, operation string, values map[string]any) error {
|
||||
operationCtx, cancel, err := publisher.operationContext(ctx, operation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := publisher.client.XAdd(operationCtx, &redis.XAddArgs{
|
||||
Stream: publisher.stream,
|
||||
MaxLen: publisher.streamMaxLen,
|
||||
Approx: true,
|
||||
Values: values,
|
||||
}).Err(); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (publisher *Publisher) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil publisher", operation)
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
|
||||
operationCtx, cancel := context.WithTimeout(ctx, publisher.operationTimeout)
|
||||
return operationCtx, cancel, nil
|
||||
}
|
||||
|
||||
func buildEnvelope(eventType string, userID string, occurredAt time.Time, source string, traceID string) map[string]any {
|
||||
values := map[string]any{
|
||||
"event_type": eventType,
|
||||
"user_id": userID,
|
||||
"occurred_at_ms": strconv.FormatInt(occurredAt.UTC().UnixMilli(), 10),
|
||||
"source": source,
|
||||
}
|
||||
if traceID != "" {
|
||||
values["trace_id"] = traceID
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
func traceIDFromContext(ctx context.Context, fallback string) string {
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if !spanContext.IsValid() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return spanContext.TraceID().String()
|
||||
}
|
||||
|
||||
var (
|
||||
_ interface{ Close() error } = (*Publisher)(nil)
|
||||
_ interface{ Ping(context.Context) error } = (*Publisher)(nil)
|
||||
_ ports.ProfileChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.SettingsChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.EntitlementChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.SanctionChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.LimitChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.DeclaredCountryChangedPublisher = (*Publisher)(nil)
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
package domainevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:test_events",
|
||||
StreamMaxLen: 5,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
occurredAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt,
|
||||
Source: common.Source("gateway_self_service"),
|
||||
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: common.RaceName("Nova Prime"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, ports.ProfileChangedEventType, entries[0].Values["event_type"])
|
||||
require.Equal(t, "user-123", entries[0].Values["user_id"])
|
||||
require.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), entries[0].Values["occurred_at_ms"])
|
||||
require.Equal(t, "gateway_self_service", entries[0].Values["source"])
|
||||
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", entries[0].Values["trace_id"])
|
||||
require.Equal(t, string(ports.ProfileChangedOperationUpdated), entries[0].Values["operation"])
|
||||
require.Equal(t, "Nova Prime", entries[0].Values["race_name"])
|
||||
|
||||
for index := 0; index < 20; index++ {
|
||||
err = publisher.PublishSettingsChanged(context.Background(), ports.SettingsChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt.Add(time.Duration(index+1) * time.Second),
|
||||
Source: common.Source("gateway_self_service"),
|
||||
Operation: ports.SettingsChangedOperationUpdated,
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
length, err := publisher.client.XLen(context.Background(), publisher.stream).Result()
|
||||
require.NoError(t, err)
|
||||
require.LessOrEqual(t, length, int64(20))
|
||||
}
|
||||
|
||||
func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:test_events",
|
||||
StreamMaxLen: 5,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: common.RaceName("Nova Prime"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
length, xLenErr := publisher.client.XLen(context.Background(), publisher.stream).Result()
|
||||
require.NoError(t, xLenErr)
|
||||
require.Zero(t, length)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"galaxy/user/internal/adapters/redisstate"
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var knownSanctionCodes = []policy.SanctionCode{
|
||||
policy.SanctionCodeLoginBlock,
|
||||
policy.SanctionCodePrivateGameCreateBlock,
|
||||
policy.SanctionCodePrivateGameManageBlock,
|
||||
policy.SanctionCodeGameJoinBlock,
|
||||
policy.SanctionCodeProfileUpdateBlock,
|
||||
}
|
||||
|
||||
var knownLimitCodes = []policy.LimitCode{
|
||||
policy.LimitCodeMaxOwnedPrivateGames,
|
||||
policy.LimitCodeMaxPendingPublicApplications,
|
||||
policy.LimitCodeMaxActiveGameMemberships,
|
||||
}
|
||||
|
||||
var knownEligibilityMarkers = []policy.EligibilityMarker{
|
||||
policy.EligibilityMarkerCanLogin,
|
||||
policy.EligibilityMarkerCanCreatePrivateGame,
|
||||
policy.EligibilityMarkerCanManagePrivateGame,
|
||||
policy.EligibilityMarkerCanJoinGame,
|
||||
policy.EligibilityMarkerCanUpdateProfile,
|
||||
}
|
||||
|
||||
func (store *Store) addCreatedAtIndex(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
record account.UserAccount,
|
||||
) {
|
||||
pipe.ZAdd(ctx, store.keyspace.CreatedAtIndex(), redis.Z{
|
||||
Score: redisstate.CreatedAtScore(record.CreatedAt),
|
||||
Member: record.UserID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) syncDeclaredCountryIndex(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
previous account.UserAccount,
|
||||
current account.UserAccount,
|
||||
) {
|
||||
if !previous.DeclaredCountry.IsZero() {
|
||||
pipe.SRem(ctx, store.keyspace.DeclaredCountryIndex(previous.DeclaredCountry), current.UserID.String())
|
||||
}
|
||||
if !current.DeclaredCountry.IsZero() {
|
||||
pipe.SAdd(ctx, store.keyspace.DeclaredCountryIndex(current.DeclaredCountry), current.UserID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncEntitlementIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
snapshot entitlement.CurrentSnapshot,
|
||||
) {
|
||||
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), snapshot.UserID.String())
|
||||
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), snapshot.UserID.String())
|
||||
pipe.SAdd(ctx, store.keyspace.PaidStateIndex(paidStateFromSnapshot(snapshot)), snapshot.UserID.String())
|
||||
|
||||
pipe.ZRem(ctx, store.keyspace.FinitePaidExpiryIndex(), snapshot.UserID.String())
|
||||
if snapshot.HasFiniteExpiry() {
|
||||
pipe.ZAdd(ctx, store.keyspace.FinitePaidExpiryIndex(), redis.Z{
|
||||
Score: redisstate.ExpiryScore(*snapshot.EndsAt),
|
||||
Member: snapshot.UserID.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncActiveSanctionCodeIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
activeCodes map[policy.SanctionCode]struct{},
|
||||
) {
|
||||
for _, code := range knownSanctionCodes {
|
||||
pipe.SRem(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
|
||||
if _, ok := activeCodes[code]; ok {
|
||||
pipe.SAdd(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncActiveLimitCodeIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
activeCodes map[policy.LimitCode]struct{},
|
||||
) {
|
||||
for _, code := range knownLimitCodes {
|
||||
pipe.SRem(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
|
||||
if _, ok := activeCodes[code]; ok {
|
||||
pipe.SAdd(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncEligibilityMarkerIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
isPaid bool,
|
||||
activeSanctionCodes map[policy.SanctionCode]struct{},
|
||||
) {
|
||||
values := deriveEligibilityMarkerValues(isPaid, activeSanctionCodes)
|
||||
|
||||
for _, marker := range knownEligibilityMarkers {
|
||||
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, true), userID.String())
|
||||
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, false), userID.String())
|
||||
pipe.SAdd(ctx, store.keyspace.EligibilityMarkerIndex(marker, values[marker]), userID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveSanctionCodeSet(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
userID common.UserID,
|
||||
) (map[policy.SanctionCode]struct{}, error) {
|
||||
activeCodes := make(map[policy.SanctionCode]struct{}, len(knownSanctionCodes))
|
||||
|
||||
for _, code := range knownSanctionCodes {
|
||||
_, err := store.loadActiveSanctionRecordID(ctx, getter, store.keyspace.ActiveSanction(userID, code))
|
||||
switch {
|
||||
case err == nil:
|
||||
activeCodes[code] = struct{}{}
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
continue
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return activeCodes, nil
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveLimitCodeSet(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
userID common.UserID,
|
||||
) (map[policy.LimitCode]struct{}, error) {
|
||||
activeCodes := make(map[policy.LimitCode]struct{}, len(knownLimitCodes))
|
||||
|
||||
for _, code := range knownLimitCodes {
|
||||
_, err := store.loadActiveLimitRecordID(ctx, getter, store.keyspace.ActiveLimit(userID, code))
|
||||
switch {
|
||||
case err == nil:
|
||||
activeCodes[code] = struct{}{}
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
continue
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return activeCodes, nil
|
||||
}
|
||||
|
||||
func (store *Store) activeSanctionWatchKeys(userID common.UserID) []string {
|
||||
keys := make([]string, 0, len(knownSanctionCodes))
|
||||
for _, code := range knownSanctionCodes {
|
||||
keys = append(keys, store.keyspace.ActiveSanction(userID, code))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func (store *Store) activeLimitWatchKeys(userID common.UserID) []string {
|
||||
keys := make([]string, 0, len(knownLimitCodes))
|
||||
for _, code := range knownLimitCodes {
|
||||
keys = append(keys, store.keyspace.ActiveLimit(userID, code))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func deriveEligibilityMarkerValues(
|
||||
isPaid bool,
|
||||
activeSanctionCodes map[policy.SanctionCode]struct{},
|
||||
) map[policy.EligibilityMarker]bool {
|
||||
_, loginBlocked := activeSanctionCodes[policy.SanctionCodeLoginBlock]
|
||||
_, createBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameCreateBlock]
|
||||
_, manageBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameManageBlock]
|
||||
_, joinBlocked := activeSanctionCodes[policy.SanctionCodeGameJoinBlock]
|
||||
_, profileBlocked := activeSanctionCodes[policy.SanctionCodeProfileUpdateBlock]
|
||||
|
||||
canLogin := !loginBlocked
|
||||
|
||||
return map[policy.EligibilityMarker]bool{
|
||||
policy.EligibilityMarkerCanLogin: canLogin,
|
||||
policy.EligibilityMarkerCanCreatePrivateGame: canLogin && isPaid && !createBlocked,
|
||||
policy.EligibilityMarkerCanManagePrivateGame: canLogin && isPaid && !manageBlocked,
|
||||
policy.EligibilityMarkerCanJoinGame: canLogin && !joinBlocked,
|
||||
policy.EligibilityMarkerCanUpdateProfile: canLogin && !profileBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
func paidStateFromSnapshot(snapshot entitlement.CurrentSnapshot) entitlement.PaidState {
|
||||
if snapshot.IsPaid {
|
||||
return entitlement.PaidStatePaid
|
||||
}
|
||||
|
||||
return entitlement.PaidStateFree
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/adapters/redisstate"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListUserIDsCreatedAtPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
base := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
first := validAccountRecord()
|
||||
first.UserID = common.UserID("user-100")
|
||||
first.Email = common.Email("u100@example.com")
|
||||
first.RaceName = common.RaceName("User 100")
|
||||
first.CreatedAt = base.Add(-time.Hour)
|
||||
first.UpdatedAt = first.CreatedAt
|
||||
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-200")
|
||||
second.Email = common.Email("u200@example.com")
|
||||
second.RaceName = common.RaceName("User 200")
|
||||
second.CreatedAt = base
|
||||
second.UpdatedAt = second.CreatedAt
|
||||
|
||||
third := validAccountRecord()
|
||||
third.UserID = common.UserID("user-300")
|
||||
third.Email = common.Email("u300@example.com")
|
||||
third.RaceName = common.RaceName("User 300")
|
||||
third.CreatedAt = base
|
||||
third.UpdatedAt = third.CreatedAt
|
||||
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(first)))
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(second)))
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(third)))
|
||||
|
||||
firstPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
|
||||
PageSize: 2,
|
||||
Filters: ports.UserListFilters{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.UserID{third.UserID, second.UserID}, firstPage.UserIDs)
|
||||
require.NotEmpty(t, firstPage.NextPageToken)
|
||||
|
||||
secondPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
|
||||
PageSize: 2,
|
||||
PageToken: firstPage.NextPageToken,
|
||||
Filters: ports.UserListFilters{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.UserID{first.UserID}, secondPage.UserIDs)
|
||||
require.Empty(t, secondPage.NextPageToken)
|
||||
}
|
||||
|
||||
func TestEnsureByEmailInitialAdminIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.DeclaredCountry = common.CountryCode("DE")
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
|
||||
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeCreated, result.Outcome)
|
||||
|
||||
requireSortedSetScore(t, store, store.keyspace.CreatedAtIndex(), record.UserID.String(), redisstate.CreatedAtScore(record.CreatedAt))
|
||||
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
|
||||
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(record.DeclaredCountry), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, true), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestAccountUpdateSyncsDeclaredCountryIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
record := validAccountRecord()
|
||||
record.DeclaredCountry = common.CountryCode("DE")
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updated := record
|
||||
updated.DeclaredCountry = common.CountryCode("FR")
|
||||
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.Update(context.Background(), updated))
|
||||
|
||||
requireSetNotContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("DE")), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("FR")), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestEntitlementLifecycleSyncsAdminIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lifecycleStore := store.EntitlementLifecycle()
|
||||
freeRecord := validEntitlementRecord(record.UserID, now)
|
||||
freeSnapshot := validEntitlementSnapshot(record.UserID, now)
|
||||
|
||||
grantStartsAt := now.Add(time.Hour)
|
||||
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
|
||||
grantedRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
grantedSnapshot := paidEntitlementSnapshot(
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
closedFreeRecord := freeRecord
|
||||
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
|
||||
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
|
||||
|
||||
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: freeSnapshot,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFreeRecord,
|
||||
NewRecord: grantedRecord,
|
||||
NewSnapshot: grantedSnapshot,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
|
||||
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
|
||||
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(grantEndsAt))
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, true), record.UserID.String())
|
||||
|
||||
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
|
||||
extensionRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-2"),
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantEndsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
extendedSnapshot := paidEntitlementSnapshot(
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
|
||||
ExpectedCurrentSnapshot: grantedSnapshot,
|
||||
NewRecord: extensionRecord,
|
||||
NewSnapshot: extendedSnapshot,
|
||||
}))
|
||||
|
||||
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(extendedEndsAt))
|
||||
|
||||
revokeAt := grantEndsAt.Add(12 * time.Hour)
|
||||
revokedCurrentRecord := extensionRecord
|
||||
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
|
||||
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
|
||||
freeAfterRevokeRecord := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
|
||||
UserID: record.UserID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
StartsAt: revokeAt,
|
||||
CreatedAt: revokeAt,
|
||||
}
|
||||
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
|
||||
UserID: record.UserID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: revokeAt,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
UpdatedAt: revokeAt,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
|
||||
ExpectedCurrentSnapshot: extendedSnapshot,
|
||||
ExpectedCurrentRecord: extensionRecord,
|
||||
UpdatedCurrentRecord: revokedCurrentRecord,
|
||||
NewRecord: freeAfterRevokeRecord,
|
||||
NewSnapshot: freeAfterRevokeSnapshot,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
|
||||
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
|
||||
requireSortedSetMissing(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestPolicyLifecycleSyncsAdminIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lifecycleStore := store.PolicyLifecycle()
|
||||
sanctionRecord := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-1"),
|
||||
UserID: record.UserID,
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
||||
NewRecord: sanctionRecord,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, false), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, false), record.UserID.String())
|
||||
|
||||
removedSanction := sanctionRecord
|
||||
removedAt := now.Add(time.Minute)
|
||||
removedSanction.RemovedAt = &removedAt
|
||||
removedSanction.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
removedSanction.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
|
||||
ExpectedActiveRecord: sanctionRecord,
|
||||
UpdatedRecord: removedSanction,
|
||||
}))
|
||||
|
||||
requireSetNotContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
|
||||
|
||||
limitRecord := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-1"),
|
||||
UserID: record.UserID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 5,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(2 * time.Minute),
|
||||
}
|
||||
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
||||
NewRecord: limitRecord,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
|
||||
|
||||
removedLimit := limitRecord
|
||||
limitRemovedAt := now.Add(3 * time.Minute)
|
||||
removedLimit.RemovedAt = &limitRemovedAt
|
||||
removedLimit.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
removedLimit.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
|
||||
ExpectedActiveRecord: limitRecord,
|
||||
UpdatedRecord: removedLimit,
|
||||
}))
|
||||
|
||||
requireSetNotContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestAdminListerReevaluatesExpiredPaidSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
userID := common.UserID("user-123")
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.CreatedAt = now.Add(-2 * time.Hour)
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(userID, record.CreatedAt),
|
||||
EntitlementRecord: validEntitlementRecord(userID, record.CreatedAt),
|
||||
Reservation: raceNameReservation(userID, record.RaceName, record.CreatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
grantStartsAt := now.Add(-90 * time.Minute)
|
||||
grantEndsAt := now.Add(-30 * time.Minute)
|
||||
freeRecord := validEntitlementRecord(userID, record.CreatedAt)
|
||||
freeSnapshot := validEntitlementSnapshot(userID, record.CreatedAt)
|
||||
grantedRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-expired"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
grantedSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
closedFreeRecord := freeRecord
|
||||
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
|
||||
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
|
||||
require.NoError(t, store.EntitlementLifecycle().Grant(context.Background(), ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: freeSnapshot,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFreeRecord,
|
||||
NewRecord: grantedRecord,
|
||||
NewSnapshot: grantedSnapshot,
|
||||
}))
|
||||
|
||||
reader, err := entitlementsvc.NewReader(
|
||||
store.EntitlementSnapshots(),
|
||||
store.EntitlementLifecycle(),
|
||||
adminStoreClock{now: now},
|
||||
adminStoreIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
lister, err := adminusers.NewLister(store.Accounts(), reader, store.Sanctions(), store.Limits(), adminStoreClock{now: now}, store)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := lister.Execute(context.Background(), adminusers.ListUsersInput{PaidState: "free"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 1)
|
||||
require.Equal(t, "user-123", result.Items[0].UserID)
|
||||
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
|
||||
require.False(t, result.Items[0].Entitlement.IsPaid)
|
||||
|
||||
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
|
||||
require.False(t, storedSnapshot.IsPaid)
|
||||
}
|
||||
|
||||
type adminStoreClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock adminStoreClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type adminStoreIDGenerator struct {
|
||||
entitlementRecordID entitlement.EntitlementRecordID
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return generator.entitlementRecordID, nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func requireSetContains(t *testing.T, store *Store, key string, member string) {
|
||||
t.Helper()
|
||||
|
||||
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists, "expected %q to contain %q", key, member)
|
||||
}
|
||||
|
||||
func requireSetNotContains(t *testing.T, store *Store, key string, member string) {
|
||||
t.Helper()
|
||||
|
||||
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
|
||||
require.NoError(t, err)
|
||||
require.False(t, exists, "expected %q not to contain %q", key, member)
|
||||
}
|
||||
|
||||
func requireSortedSetScore(t *testing.T, store *Store, key string, member string, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got, err := store.client.ZScore(context.Background(), key, member).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func requireSortedSetMissing(t *testing.T, store *Store, key string, member string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := store.client.ZScore(context.Background(), key, member).Result()
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type entitlementPeriodRecord struct {
|
||||
RecordID string `json:"record_id"`
|
||||
UserID string `json:"user_id"`
|
||||
PlanCode string `json:"plan_code"`
|
||||
Source string `json:"source"`
|
||||
ActorType string `json:"actor_type"`
|
||||
ActorID *string `json:"actor_id,omitempty"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt *string `json:"ends_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ClosedAt *string `json:"closed_at,omitempty"`
|
||||
ClosedByType *string `json:"closed_by_type,omitempty"`
|
||||
ClosedByID *string `json:"closed_by_id,omitempty"`
|
||||
ClosedReasonCode *string `json:"closed_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
// CreateEntitlementRecord stores one new entitlement history record.
|
||||
func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("create entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
payload, err := marshalEntitlementPeriodRecord(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(record.UserID)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
|
||||
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, payload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(record.StartsAt.UTC().UnixMicro()),
|
||||
Member: record.RecordID.String(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, recordKey, historyKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetEntitlementRecordByRecordID returns the entitlement history record
|
||||
// identified by recordID.
|
||||
func (store *Store) GetEntitlementRecordByRecordID(
|
||||
ctx context.Context,
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
) (entitlement.PeriodRecord, error) {
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id from redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record by record id from redis")
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
record, err := store.loadEntitlementRecord(operationCtx, store.client, recordID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, ports.ErrNotFound)
|
||||
default:
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// ListEntitlementRecordsByUserID returns every entitlement history record
|
||||
// owned by userID.
|
||||
func (store *Store) ListEntitlementRecordsByUserID(
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
) ([]entitlement.PeriodRecord, error) {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("list entitlement records by user id from redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records by user id from redis")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.EntitlementHistory(userID), 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
|
||||
}
|
||||
|
||||
records := make([]entitlement.PeriodRecord, 0, len(recordIDs))
|
||||
for _, rawRecordID := range recordIDs {
|
||||
record, err := store.loadEntitlementRecord(operationCtx, store.client, entitlement.EntitlementRecordID(rawRecordID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateEntitlementRecord replaces one stored entitlement history record.
|
||||
func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("update entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
payload, err := marshalEntitlementPeriodRecord(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if _, err := store.loadEntitlementRecord(operationCtx, tx, record.RecordID); err != nil {
|
||||
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, payload, 0)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, recordKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GrantEntitlement atomically closes the current free history record, creates
|
||||
// one paid history record, and replaces the current snapshot.
|
||||
func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "grant entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExtendEntitlement atomically appends one paid history segment and replaces
|
||||
// the current paid snapshot.
|
||||
func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "extend entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeEntitlement atomically closes the current paid history record,
|
||||
// creates one free history record, and replaces the current snapshot.
|
||||
func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "revoke entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RepairExpiredEntitlement atomically replaces one expired finite paid
|
||||
// snapshot with a materialized free state.
|
||||
func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "repair expired entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedExpiredSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedExpiredSnapshot) {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) loadEntitlementRecord(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
) (entitlement.PeriodRecord, error) {
|
||||
payload, err := getter.Get(ctx, store.keyspace.EntitlementRecord(recordID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return entitlement.PeriodRecord{}, ports.ErrNotFound
|
||||
case err != nil:
|
||||
return entitlement.PeriodRecord{}, err
|
||||
}
|
||||
|
||||
return decodeEntitlementPeriodRecord(payload)
|
||||
}
|
||||
|
||||
func marshalEntitlementPeriodRecord(record entitlement.PeriodRecord) ([]byte, error) {
|
||||
encoded := entitlementPeriodRecord{
|
||||
RecordID: record.RecordID.String(),
|
||||
UserID: record.UserID.String(),
|
||||
PlanCode: string(record.PlanCode),
|
||||
Source: record.Source.String(),
|
||||
ActorType: record.Actor.Type.String(),
|
||||
ReasonCode: record.ReasonCode.String(),
|
||||
StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano),
|
||||
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if !record.Actor.ID.IsZero() {
|
||||
value := record.Actor.ID.String()
|
||||
encoded.ActorID = &value
|
||||
}
|
||||
if record.EndsAt != nil {
|
||||
value := record.EndsAt.UTC().Format(time.RFC3339Nano)
|
||||
encoded.EndsAt = &value
|
||||
}
|
||||
if record.ClosedAt != nil {
|
||||
value := record.ClosedAt.UTC().Format(time.RFC3339Nano)
|
||||
encoded.ClosedAt = &value
|
||||
}
|
||||
if !record.ClosedBy.Type.IsZero() {
|
||||
value := record.ClosedBy.Type.String()
|
||||
encoded.ClosedByType = &value
|
||||
}
|
||||
if !record.ClosedBy.ID.IsZero() {
|
||||
value := record.ClosedBy.ID.String()
|
||||
encoded.ClosedByID = &value
|
||||
}
|
||||
if !record.ClosedReasonCode.IsZero() {
|
||||
value := record.ClosedReasonCode.String()
|
||||
encoded.ClosedReasonCode = &value
|
||||
}
|
||||
|
||||
return json.Marshal(encoded)
|
||||
}
|
||||
|
||||
func decodeEntitlementPeriodRecord(payload []byte) (entitlement.PeriodRecord, error) {
|
||||
var encoded entitlementPeriodRecord
|
||||
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
||||
return entitlement.PeriodRecord{}, err
|
||||
}
|
||||
|
||||
startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record starts_at: %w", err)
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record created_at: %w", err)
|
||||
}
|
||||
|
||||
record := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID(encoded.RecordID),
|
||||
UserID: common.UserID(encoded.UserID),
|
||||
PlanCode: entitlement.PlanCode(encoded.PlanCode),
|
||||
Source: common.Source(encoded.Source),
|
||||
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
|
||||
ReasonCode: common.ReasonCode(encoded.ReasonCode),
|
||||
StartsAt: startsAt.UTC(),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
}
|
||||
if encoded.ActorID != nil {
|
||||
record.Actor.ID = common.ActorID(*encoded.ActorID)
|
||||
}
|
||||
if encoded.EndsAt != nil {
|
||||
value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record ends_at: %w", err)
|
||||
}
|
||||
value = value.UTC()
|
||||
record.EndsAt = &value
|
||||
}
|
||||
if encoded.ClosedAt != nil {
|
||||
value, err := time.Parse(time.RFC3339Nano, *encoded.ClosedAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record closed_at: %w", err)
|
||||
}
|
||||
value = value.UTC()
|
||||
record.ClosedAt = &value
|
||||
}
|
||||
if encoded.ClosedByType != nil {
|
||||
record.ClosedBy.Type = common.ActorType(*encoded.ClosedByType)
|
||||
}
|
||||
if encoded.ClosedByID != nil {
|
||||
record.ClosedBy.ID = common.ActorID(*encoded.ClosedByID)
|
||||
}
|
||||
if encoded.ClosedReasonCode != nil {
|
||||
record.ClosedReasonCode = common.ReasonCode(*encoded.ClosedReasonCode)
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func equalEntitlementSnapshots(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool {
|
||||
return left.UserID == right.UserID &&
|
||||
left.PlanCode == right.PlanCode &&
|
||||
left.IsPaid == right.IsPaid &&
|
||||
left.StartsAt.Equal(right.StartsAt) &&
|
||||
equalOptionalTime(left.EndsAt, right.EndsAt) &&
|
||||
left.Source == right.Source &&
|
||||
left.Actor == right.Actor &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.UpdatedAt.Equal(right.UpdatedAt)
|
||||
}
|
||||
|
||||
func equalEntitlementPeriodRecords(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool {
|
||||
return left.RecordID == right.RecordID &&
|
||||
left.UserID == right.UserID &&
|
||||
left.PlanCode == right.PlanCode &&
|
||||
left.Source == right.Source &&
|
||||
left.Actor == right.Actor &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.StartsAt.Equal(right.StartsAt) &&
|
||||
equalOptionalTime(left.EndsAt, right.EndsAt) &&
|
||||
left.CreatedAt.Equal(right.CreatedAt) &&
|
||||
equalOptionalTime(left.ClosedAt, right.ClosedAt) &&
|
||||
left.ClosedBy == right.ClosedBy &&
|
||||
left.ClosedReasonCode == right.ClosedReasonCode
|
||||
}
|
||||
|
||||
func equalOptionalTime(left *time.Time, right *time.Time) bool {
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return true
|
||||
case left == nil || right == nil:
|
||||
return false
|
||||
default:
|
||||
return left.Equal(*right)
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementHistoryStore adapts Store to the existing
|
||||
// EntitlementHistoryStore port.
|
||||
type EntitlementHistoryStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// EntitlementHistory returns one adapter that exposes the entitlement-history
|
||||
// store port over Store.
|
||||
func (store *Store) EntitlementHistory() *EntitlementHistoryStore {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &EntitlementHistoryStore{store: store}
|
||||
}
|
||||
|
||||
// Create stores one new entitlement history record.
|
||||
func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
return adapter.store.CreateEntitlementRecord(ctx, record)
|
||||
}
|
||||
|
||||
// GetByRecordID returns the entitlement history record identified by recordID.
|
||||
func (adapter *EntitlementHistoryStore) GetByRecordID(
|
||||
ctx context.Context,
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
) (entitlement.PeriodRecord, error) {
|
||||
return adapter.store.GetEntitlementRecordByRecordID(ctx, recordID)
|
||||
}
|
||||
|
||||
// ListByUserID returns every entitlement history record owned by userID.
|
||||
func (adapter *EntitlementHistoryStore) ListByUserID(
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
) ([]entitlement.PeriodRecord, error) {
|
||||
return adapter.store.ListEntitlementRecordsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// Update replaces one stored entitlement history record.
|
||||
func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
return adapter.store.UpdateEntitlementRecord(ctx, record)
|
||||
}
|
||||
|
||||
var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil)
|
||||
|
||||
// EntitlementLifecycleStore adapts Store to the existing
|
||||
// EntitlementLifecycleStore port.
|
||||
type EntitlementLifecycleStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// EntitlementLifecycle returns one adapter that exposes the atomic
|
||||
// entitlement-lifecycle store port over Store.
|
||||
func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &EntitlementLifecycleStore{store: store}
|
||||
}
|
||||
|
||||
// Grant atomically applies one free-to-paid transition.
|
||||
func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error {
|
||||
return adapter.store.GrantEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
// Extend atomically appends one paid extension segment and updates the current
|
||||
// snapshot.
|
||||
func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error {
|
||||
return adapter.store.ExtendEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
// Revoke atomically applies one paid-to-free transition.
|
||||
func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error {
|
||||
return adapter.store.RevokeEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
// RepairExpired atomically repairs one expired finite paid snapshot.
|
||||
func (adapter *EntitlementLifecycleStore) RepairExpired(
|
||||
ctx context.Context,
|
||||
input ports.RepairExpiredEntitlementInput,
|
||||
) error {
|
||||
return adapter.store.RepairExpiredEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)
|
||||
@@ -0,0 +1,137 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/adapters/redisstate"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ListUserIDs returns one deterministic page of user identifiers ordered by
|
||||
// `created_at desc`, then `user_id desc`.
|
||||
func (store *Store) ListUserIDs(ctx context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "list users in redis")
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
startIndex := int64(0)
|
||||
filters := userListFiltersFromPorts(input.Filters)
|
||||
if input.PageToken != "" {
|
||||
cursor, err := redisstate.DecodePageToken(input.PageToken, filters)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
}
|
||||
|
||||
score, err := store.client.ZScore(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
case err != nil:
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
if !time.UnixMicro(int64(score)).UTC().Equal(cursor.CreatedAt.UTC()) {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
}
|
||||
|
||||
rank, err := store.client.ZRevRank(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
case err != nil:
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
|
||||
startIndex = rank + 1
|
||||
}
|
||||
|
||||
rawPage, err := store.client.ZRevRangeWithScores(
|
||||
operationCtx,
|
||||
store.keyspace.CreatedAtIndex(),
|
||||
startIndex,
|
||||
startIndex+int64(input.PageSize),
|
||||
).Result()
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
|
||||
result := ports.ListUsersResult{
|
||||
UserIDs: make([]common.UserID, 0, min(len(rawPage), input.PageSize)),
|
||||
}
|
||||
|
||||
visibleCount := min(len(rawPage), input.PageSize)
|
||||
for index := 0; index < visibleCount; index++ {
|
||||
userID, err := memberUserID(rawPage[index].Member)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
result.UserIDs = append(result.UserIDs, userID)
|
||||
}
|
||||
|
||||
if len(rawPage) > input.PageSize {
|
||||
lastVisible := rawPage[input.PageSize-1]
|
||||
lastUserID, err := memberUserID(lastVisible.Member)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
token, err := redisstate.EncodePageToken(redisstate.PageCursor{
|
||||
CreatedAt: time.UnixMicro(int64(lastVisible.Score)).UTC(),
|
||||
UserID: lastUserID,
|
||||
}, filters)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
result.NextPageToken = token
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func userListFiltersFromPorts(filters ports.UserListFilters) redisstate.UserListFilters {
|
||||
return redisstate.UserListFilters{
|
||||
PaidState: filters.PaidState,
|
||||
PaidExpiresBefore: filters.PaidExpiresBefore,
|
||||
PaidExpiresAfter: filters.PaidExpiresAfter,
|
||||
DeclaredCountry: filters.DeclaredCountry,
|
||||
SanctionCode: filters.SanctionCode,
|
||||
LimitCode: filters.LimitCode,
|
||||
CanLogin: filters.CanLogin,
|
||||
CanCreatePrivateGame: filters.CanCreatePrivateGame,
|
||||
CanJoinGame: filters.CanJoinGame,
|
||||
}
|
||||
}
|
||||
|
||||
func memberUserID(member any) (common.UserID, error) {
|
||||
value, ok := member.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected created-at index member type %T", member)
|
||||
}
|
||||
|
||||
userID := common.UserID(value)
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("created-at index member user id: %w", err)
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func min(left int, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
|
||||
return right
|
||||
}
|
||||
|
||||
var _ ports.UserListStore = (*Store)(nil)
|
||||
@@ -0,0 +1,445 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ApplySanction atomically creates one new active sanction record.
|
||||
func (store *Store) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("apply sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
recordPayload, err := marshalSanctionRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.SanctionRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.SanctionHistory(input.NewRecord.UserID)
|
||||
activeKey := store.keyspace.ActiveSanction(input.NewRecord.UserID, input.NewRecord.SanctionCode)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewRecord.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{recordKey, historyKey, activeKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "apply sanction in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.NewRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
activeSanctionCodes[input.NewRecord.SanctionCode] = struct{}{}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, recordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
|
||||
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeSanctionCodes)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSanction atomically removes one active sanction record.
|
||||
func (store *Store) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("remove sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedPayload, err := marshalSanctionRecord(input.UpdatedRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.SanctionRecord(input.ExpectedActiveRecord.RecordID)
|
||||
activeKey := store.keyspace.ActiveSanction(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.SanctionCode)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.ExpectedActiveRecord.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{recordKey, activeKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.ExpectedActiveRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "remove sanction in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
activeRecordID, err := store.loadActiveSanctionRecordID(operationCtx, tx, activeKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if activeRecordID != input.ExpectedActiveRecord.RecordID {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedRecord, err := store.loadSanctionRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if !equalSanctionRecords(storedRecord, input.ExpectedActiveRecord) {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedActiveRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
delete(activeSanctionCodes, input.ExpectedActiveRecord.SanctionCode)
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
|
||||
pipe.Del(operationCtx, activeKey)
|
||||
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeSanctionCodes)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimit atomically creates or replaces one active limit record.
|
||||
func (store *Store) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordPayload, err := marshalLimitRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordKey := store.keyspace.LimitRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.LimitHistory(input.NewRecord.UserID)
|
||||
activeKey := store.keyspace.ActiveLimit(input.NewRecord.UserID, input.NewRecord.LimitCode)
|
||||
watchedKeys := append(
|
||||
[]string{newRecordKey, historyKey, activeKey},
|
||||
store.activeLimitWatchKeys(input.NewRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "set limit in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
if input.ExpectedActiveRecord != nil {
|
||||
watchedKeys = append(watchedKeys, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID))
|
||||
}
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
|
||||
var updatedPayload []byte
|
||||
if input.ExpectedActiveRecord == nil {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
} else {
|
||||
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
if activeRecordID != input.ExpectedActiveRecord.RecordID {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
if !equalLimitRecords(storedRecord, *input.ExpectedActiveRecord) {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
updatedPayload, err = marshalLimitRecord(*input.UpdatedActiveRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
}
|
||||
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.NewRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
activeLimitCodes[input.NewRecord.LimitCode] = struct{}{}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
if input.ExpectedActiveRecord != nil {
|
||||
pipe.Set(operationCtx, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID), updatedPayload, 0)
|
||||
}
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
|
||||
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeLimitCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveLimit atomically removes one active limit record.
|
||||
func (store *Store) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("remove limit in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedPayload, err := marshalLimitRecord(input.UpdatedRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID)
|
||||
activeKey := store.keyspace.ActiveLimit(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.LimitCode)
|
||||
watchedKeys := append(
|
||||
[]string{recordKey, activeKey},
|
||||
store.activeLimitWatchKeys(input.ExpectedActiveRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "remove limit in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if activeRecordID != input.ExpectedActiveRecord.RecordID {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if !equalLimitRecords(storedRecord, input.ExpectedActiveRecord) {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
delete(activeLimitCodes, input.ExpectedActiveRecord.LimitCode)
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
|
||||
pipe.Del(operationCtx, activeKey)
|
||||
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeLimitCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveSanctionRecordID(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key string,
|
||||
) (policy.SanctionRecordID, error) {
|
||||
value, err := getter.Get(ctx, key).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return "", ports.ErrNotFound
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
|
||||
recordID := policy.SanctionRecordID(value)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("active sanction record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveLimitRecordID(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key string,
|
||||
) (policy.LimitRecordID, error) {
|
||||
value, err := getter.Get(ctx, key).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return "", ports.ErrNotFound
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
|
||||
recordID := policy.LimitRecordID(value)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("active limit record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
func setActiveSlot(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
key string,
|
||||
recordID string,
|
||||
expiresAt *time.Time,
|
||||
) {
|
||||
pipe.Set(ctx, key, recordID, 0)
|
||||
if expiresAt != nil {
|
||||
pipe.PExpireAt(ctx, key, expiresAt.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
func equalSanctionRecords(left policy.SanctionRecord, right policy.SanctionRecord) bool {
|
||||
return left.RecordID == right.RecordID &&
|
||||
left.UserID == right.UserID &&
|
||||
left.SanctionCode == right.SanctionCode &&
|
||||
left.Scope == right.Scope &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.Actor == right.Actor &&
|
||||
left.AppliedAt.Equal(right.AppliedAt) &&
|
||||
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
|
||||
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
|
||||
left.RemovedBy == right.RemovedBy &&
|
||||
left.RemovedReasonCode == right.RemovedReasonCode
|
||||
}
|
||||
|
||||
func equalLimitRecords(left policy.LimitRecord, right policy.LimitRecord) bool {
|
||||
return left.RecordID == right.RecordID &&
|
||||
left.UserID == right.UserID &&
|
||||
left.LimitCode == right.LimitCode &&
|
||||
left.Value == right.Value &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.Actor == right.Actor &&
|
||||
left.AppliedAt.Equal(right.AppliedAt) &&
|
||||
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
|
||||
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
|
||||
left.RemovedBy == right.RemovedBy &&
|
||||
left.RemovedReasonCode == right.RemovedReasonCode
|
||||
}
|
||||
|
||||
// PolicyLifecycleStore adapts Store to the existing PolicyLifecycleStore
|
||||
// port.
|
||||
type PolicyLifecycleStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// PolicyLifecycle returns one adapter that exposes the atomic policy-lifecycle
|
||||
// store port over Store.
|
||||
func (store *Store) PolicyLifecycle() *PolicyLifecycleStore {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &PolicyLifecycleStore{store: store}
|
||||
}
|
||||
|
||||
// ApplySanction atomically creates one new active sanction record.
|
||||
func (adapter *PolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
|
||||
return adapter.store.ApplySanction(ctx, input)
|
||||
}
|
||||
|
||||
// RemoveSanction atomically removes one active sanction record.
|
||||
func (adapter *PolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
|
||||
return adapter.store.RemoveSanction(ctx, input)
|
||||
}
|
||||
|
||||
// SetLimit atomically creates or replaces one active limit record.
|
||||
func (adapter *PolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
|
||||
return adapter.store.SetLimit(ctx, input)
|
||||
}
|
||||
|
||||
// RemoveLimit atomically removes one active limit record.
|
||||
func (adapter *PolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
|
||||
return adapter.store.RemoveLimit(ctx, input)
|
||||
}
|
||||
|
||||
var _ ports.PolicyLifecycleStore = (*PolicyLifecycleStore)(nil)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,930 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/authblock"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountStoreCreateAndLookups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byUserID)
|
||||
|
||||
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byEmail)
|
||||
|
||||
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byRaceName)
|
||||
|
||||
exists, err := accountStore.ExistsByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID, reservation.UserID)
|
||||
require.Equal(t, record.RaceName, reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestBlockedEmailStoreUpsertAndGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
blockedEmailStore := store.BlockedEmails()
|
||||
|
||||
record := authblock.BlockedEmailSubject{
|
||||
Email: common.Email("blocked@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
ResolvedUserID: common.UserID("user-123"),
|
||||
}
|
||||
require.NoError(t, blockedEmailStore.Upsert(context.Background(), record))
|
||||
|
||||
got, err := blockedEmailStore.GetByEmail(context.Background(), record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, got)
|
||||
}
|
||||
|
||||
func TestEnsureResolveAndBlockFlows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
accountRecord := validAccountRecord()
|
||||
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
|
||||
|
||||
created, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(accountRecord.RaceName))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, accountRecord.UserID, reservation.UserID)
|
||||
|
||||
entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entitlementHistory, 1)
|
||||
require.Equal(t, validEntitlementRecord(accountRecord.UserID, now), entitlementHistory[0])
|
||||
|
||||
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindExisting, resolved.Kind)
|
||||
|
||||
blockedByUserID, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: accountRecord.UserID,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, blockedByUserID.Outcome)
|
||||
|
||||
repeatedBlock, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now.Add(2 * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, repeatedBlock.Outcome)
|
||||
require.Equal(t, accountRecord.UserID, repeatedBlock.UserID)
|
||||
|
||||
blockedResolution, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindBlocked, blockedResolution.Kind)
|
||||
|
||||
ensureBlocked, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensureBlocked.Outcome)
|
||||
}
|
||||
|
||||
func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
accountRecord := validAccountRecord()
|
||||
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
|
||||
|
||||
blocked, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, blocked.Outcome)
|
||||
require.True(t, blocked.UserID.IsZero())
|
||||
|
||||
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindBlocked, resolved.Kind)
|
||||
|
||||
ensured, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensured.Outcome)
|
||||
|
||||
exists, err := store.ExistsByUserID(context.Background(), accountRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
existingAccount := account.UserAccount{
|
||||
UserID: common.UserID("user-existing"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(existingAccount)))
|
||||
|
||||
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: existingAccount.Email,
|
||||
Account: account.UserAccount{
|
||||
UserID: common.UserID("user-created"),
|
||||
Email: existingAccount.Email,
|
||||
RaceName: common.RaceName("player-new123"),
|
||||
PreferredLanguage: common.LanguageTag("fr-FR"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
CreatedAt: createdAt.Add(time.Minute),
|
||||
UpdatedAt: createdAt.Add(time.Minute),
|
||||
},
|
||||
Entitlement: validEntitlementSnapshot(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
||||
EntitlementRecord: validEntitlementRecord(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
||||
Reservation: raceNameReservation(common.UserID("user-created"), common.RaceName("player-new123"), createdAt.Add(time.Minute)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeExisting, result.Outcome)
|
||||
require.Equal(t, existingAccount.UserID, result.UserID)
|
||||
|
||||
storedAccount, err := store.GetByEmail(context.Background(), existingAccount.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, existingAccount, storedAccount)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameSwapsLookupAtomically(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updatedAt := record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("Nova Prime"), updatedAt)))
|
||||
|
||||
stored, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), stored.RaceName)
|
||||
require.True(t, stored.UpdatedAt.Equal(updatedAt))
|
||||
|
||||
_, err = accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
|
||||
renamed, err := accountStore.GetByRaceName(context.Background(), common.RaceName("Nova Prime"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID, renamed.UserID)
|
||||
|
||||
_, err = store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("Nova Prime")))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameAllowsSameOwnerCanonicalSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
record.RaceName = common.RaceName("Pilot Nova")
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updatedAt := record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("P1lot Nova"), updatedAt)))
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("P1lot Nova")))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("P1lot Nova"), reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameReturnsConflictWhenTargetExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
first := validAccountRecord()
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-456")
|
||||
second.Email = common.Email("other@example.com")
|
||||
second.RaceName = common.RaceName("Taken Name")
|
||||
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(second)))
|
||||
|
||||
err := accountStore.RenameRaceName(context.Background(), renameRaceNameInput(first, second.RaceName, first.UpdatedAt.Add(time.Minute)))
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
|
||||
stored, err := accountStore.GetByUserID(context.Background(), first.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.RaceName, stored.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updated := record
|
||||
updated.DeclaredCountry = common.CountryCode("FR")
|
||||
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
|
||||
require.NoError(t, accountStore.Update(context.Background(), updated))
|
||||
|
||||
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byUserID)
|
||||
|
||||
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byEmail)
|
||||
|
||||
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byRaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
first := validAccountRecord()
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-456")
|
||||
second.Email = common.Email("other@example.com")
|
||||
second.RaceName = common.RaceName("P1lot Nova")
|
||||
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
||||
|
||||
err := accountStore.Create(context.Background(), createAccountInput(second))
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
}
|
||||
|
||||
func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
accountRecord := validAccountRecord()
|
||||
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(accountRecord)))
|
||||
|
||||
first, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: accountRecord.UserID,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, first.Outcome)
|
||||
|
||||
second, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: accountRecord.UserID,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, second.Outcome)
|
||||
require.Equal(t, accountRecord.UserID, second.UserID)
|
||||
}
|
||||
|
||||
func TestBlockByUserIDUnknownUserReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
|
||||
_, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: common.UserID("user-missing"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestSanctionAndLimitStoresRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
sanctionStore := store.Sanctions()
|
||||
limitStore := store.Limits()
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
sanctionRecord := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("self_service"),
|
||||
ReasonCode: common.ReasonCode("policy_enforced"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, sanctionStore.Create(context.Background(), sanctionRecord))
|
||||
|
||||
gotSanction, err := sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sanctionRecord, gotSanction)
|
||||
|
||||
sanctions, err := sanctionStore.ListByUserID(context.Background(), sanctionRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sanctions, 1)
|
||||
|
||||
expiresAt := now.Add(time.Hour)
|
||||
sanctionRecord.ExpiresAt = &expiresAt
|
||||
require.NoError(t, sanctionStore.Update(context.Background(), sanctionRecord))
|
||||
|
||||
gotSanction, err = sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sanctionRecord.RecordID, gotSanction.RecordID)
|
||||
require.Equal(t, sanctionRecord.UserID, gotSanction.UserID)
|
||||
require.Equal(t, sanctionRecord.SanctionCode, gotSanction.SanctionCode)
|
||||
require.Equal(t, sanctionRecord.Scope, gotSanction.Scope)
|
||||
require.Equal(t, sanctionRecord.ReasonCode, gotSanction.ReasonCode)
|
||||
require.Equal(t, sanctionRecord.Actor, gotSanction.Actor)
|
||||
require.True(t, gotSanction.AppliedAt.Equal(sanctionRecord.AppliedAt))
|
||||
require.NotNil(t, gotSanction.ExpiresAt)
|
||||
require.True(t, gotSanction.ExpiresAt.Equal(*sanctionRecord.ExpiresAt))
|
||||
|
||||
limitRecord := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("policy_enforced"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, limitStore.Create(context.Background(), limitRecord))
|
||||
|
||||
gotLimit, err := limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, limitRecord, gotLimit)
|
||||
|
||||
limits, err := limitStore.ListByUserID(context.Background(), limitRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, limits, 1)
|
||||
|
||||
limitRecord.Value = 5
|
||||
require.NoError(t, limitStore.Update(context.Background(), limitRecord))
|
||||
|
||||
gotLimit, err = limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, limitRecord, gotLimit)
|
||||
}
|
||||
|
||||
func TestPolicyLifecycleApplyAndRemoveSanction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
lifecycleStore := store.PolicyLifecycle()
|
||||
sanctionStore := store.Sanctions()
|
||||
snapshotStore := store.EntitlementSnapshots()
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
require.NoError(t, snapshotStore.Put(context.Background(), validEntitlementSnapshot(userID, now)))
|
||||
|
||||
record := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-1"),
|
||||
UserID: userID,
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
||||
NewRecord: record,
|
||||
}))
|
||||
|
||||
activeRecordID, err := store.loadActiveSanctionRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.RecordID, activeRecordID)
|
||||
|
||||
err = lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
||||
NewRecord: policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-2"),
|
||||
UserID: userID,
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
AppliedAt: now.Add(time.Minute),
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
|
||||
removed := record
|
||||
removedAt := now.Add(30 * time.Minute)
|
||||
removed.RemovedAt = &removedAt
|
||||
removed.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
removed.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
|
||||
ExpectedActiveRecord: record,
|
||||
UpdatedRecord: removed,
|
||||
}))
|
||||
|
||||
stored, err := sanctionStore.GetByRecordID(context.Background(), record.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, removed, stored)
|
||||
|
||||
_, err = store.loadActiveSanctionRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
|
||||
)
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestPolicyLifecycleSetAndRemoveLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
lifecycleStore := store.PolicyLifecycle()
|
||||
limitStore := store.Limits()
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
first := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-1"),
|
||||
UserID: userID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
||||
NewRecord: first,
|
||||
}))
|
||||
|
||||
activeRecordID, err := store.loadActiveLimitRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.RecordID, activeRecordID)
|
||||
|
||||
second := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-2"),
|
||||
UserID: userID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 5,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
AppliedAt: now.Add(time.Hour),
|
||||
}
|
||||
updatedFirst := first
|
||||
removedAt := second.AppliedAt
|
||||
updatedFirst.RemovedAt = &removedAt
|
||||
updatedFirst.RemovedBy = second.Actor
|
||||
updatedFirst.RemovedReasonCode = second.ReasonCode
|
||||
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
||||
ExpectedActiveRecord: &first,
|
||||
UpdatedActiveRecord: &updatedFirst,
|
||||
NewRecord: second,
|
||||
}))
|
||||
|
||||
storedFirst, err := limitStore.GetByRecordID(context.Background(), first.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updatedFirst, storedFirst)
|
||||
|
||||
activeRecordID, err = store.loadActiveLimitRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, second.RecordID, activeRecordID)
|
||||
|
||||
removedSecond := second
|
||||
removeAt := now.Add(90 * time.Minute)
|
||||
removedSecond.RemovedAt = &removeAt
|
||||
removedSecond.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-3")}
|
||||
removedSecond.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
|
||||
ExpectedActiveRecord: second,
|
||||
UpdatedRecord: removedSecond,
|
||||
}))
|
||||
|
||||
storedSecond, err := limitStore.GetByRecordID(context.Background(), second.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, removedSecond, storedSecond)
|
||||
|
||||
_, err = store.loadActiveLimitRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
||||
)
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestEntitlementLifecycleTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
historyStore := store.EntitlementHistory()
|
||||
snapshotStore := store.EntitlementSnapshots()
|
||||
lifecycleStore := store.EntitlementLifecycle()
|
||||
userID := common.UserID("user-123")
|
||||
startedFreeAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
freeRecord := validEntitlementRecord(userID, startedFreeAt)
|
||||
freeSnapshot := validEntitlementSnapshot(userID, startedFreeAt)
|
||||
require.NoError(t, historyStore.Create(context.Background(), freeRecord))
|
||||
require.NoError(t, snapshotStore.Put(context.Background(), freeSnapshot))
|
||||
|
||||
grantStartsAt := startedFreeAt.Add(24 * time.Hour)
|
||||
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
|
||||
grantedRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
grantedSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
closedFreeRecord := freeRecord
|
||||
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
|
||||
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
|
||||
|
||||
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: freeSnapshot,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFreeRecord,
|
||||
NewRecord: grantedRecord,
|
||||
NewSnapshot: grantedSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, grantedSnapshot, storedSnapshot)
|
||||
|
||||
storedFreeRecord, err := historyStore.GetByRecordID(context.Background(), freeRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, closedFreeRecord, storedFreeRecord)
|
||||
|
||||
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
|
||||
extensionRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-2"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantEndsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
extendedSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
|
||||
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
|
||||
ExpectedCurrentSnapshot: grantedSnapshot,
|
||||
NewRecord: extensionRecord,
|
||||
NewSnapshot: extendedSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, extendedSnapshot, storedSnapshot)
|
||||
|
||||
revokeAt := grantEndsAt.Add(12 * time.Hour)
|
||||
revokedCurrentRecord := extensionRecord
|
||||
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
|
||||
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
|
||||
|
||||
freeAfterRevokeRecord := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
StartsAt: revokeAt,
|
||||
CreatedAt: revokeAt,
|
||||
}
|
||||
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: revokeAt,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
UpdatedAt: revokeAt,
|
||||
}
|
||||
|
||||
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
|
||||
ExpectedCurrentSnapshot: extendedSnapshot,
|
||||
ExpectedCurrentRecord: extensionRecord,
|
||||
UpdatedCurrentRecord: revokedCurrentRecord,
|
||||
NewRecord: freeAfterRevokeRecord,
|
||||
NewSnapshot: freeAfterRevokeSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, freeAfterRevokeSnapshot, storedSnapshot)
|
||||
|
||||
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, historyRecords, 4)
|
||||
}
|
||||
|
||||
func TestRepairExpiredEntitlementMaterializesFreeSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
historyStore := store.EntitlementHistory()
|
||||
snapshotStore := store.EntitlementSnapshots()
|
||||
lifecycleStore := store.EntitlementLifecycle()
|
||||
userID := common.UserID("user-123")
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(24 * time.Hour)
|
||||
expiredSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
expiredSnapshot.UpdatedAt = endsAt.Add(24 * time.Hour)
|
||||
expiredRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
require.NoError(t, historyStore.Create(context.Background(), expiredRecord))
|
||||
require.NoError(t, snapshotStore.Put(context.Background(), expiredSnapshot))
|
||||
|
||||
repairedAt := endsAt.Add(2 * time.Hour)
|
||||
freeRecord := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry"),
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("entitlement_expiry_repair"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
|
||||
StartsAt: endsAt,
|
||||
CreatedAt: repairedAt,
|
||||
}
|
||||
freeSnapshot := entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: endsAt,
|
||||
Source: common.Source("entitlement_expiry_repair"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
|
||||
UpdatedAt: repairedAt,
|
||||
}
|
||||
|
||||
require.NoError(t, lifecycleStore.RepairExpired(context.Background(), ports.RepairExpiredEntitlementInput{
|
||||
ExpectedExpiredSnapshot: expiredSnapshot,
|
||||
NewRecord: freeRecord,
|
||||
NewSnapshot: freeSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, freeSnapshot, storedSnapshot)
|
||||
|
||||
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, historyRecords, 2)
|
||||
require.Equal(t, freeRecord, historyRecords[1])
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
store, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
DB: 0,
|
||||
KeyspacePrefix: "user:test:",
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func validAccountRecord() account.UserAccount {
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: now,
|
||||
Source: common.Source("auth_registration"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func validEntitlementRecord(userID common.UserID, now time.Time) entitlement.PeriodRecord {
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-" + userID.String()),
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("auth_registration"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
StartsAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func paidEntitlementRecord(
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
userID common.UserID,
|
||||
planCode entitlement.PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.PeriodRecord {
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: recordID,
|
||||
UserID: userID,
|
||||
PlanCode: planCode,
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: reasonCode,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: timePointer(endsAt),
|
||||
CreatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func paidEntitlementSnapshot(
|
||||
userID common.UserID,
|
||||
planCode entitlement.PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: planCode,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: timePointer(endsAt),
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: reasonCode,
|
||||
UpdatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
utcValue := value.UTC()
|
||||
return &utcValue
|
||||
}
|
||||
|
||||
func createAccountInput(record account.UserAccount) ports.CreateAccountInput {
|
||||
return ports.CreateAccountInput{
|
||||
Account: record,
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, record.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func renameRaceNameInput(
|
||||
record account.UserAccount,
|
||||
newRaceName common.RaceName,
|
||||
updatedAt time.Time,
|
||||
) ports.RenameRaceNameInput {
|
||||
return ports.RenameRaceNameInput{
|
||||
UserID: record.UserID,
|
||||
CurrentCanonicalKey: canonicalKey(record.RaceName),
|
||||
NewRaceName: newRaceName,
|
||||
NewReservation: raceNameReservation(record.UserID, newRaceName, updatedAt),
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func raceNameReservation(
|
||||
userID common.UserID,
|
||||
raceName common.RaceName,
|
||||
reservedAt time.Time,
|
||||
) account.RaceNameReservation {
|
||||
return account.RaceNameReservation{
|
||||
CanonicalKey: canonicalKey(raceName),
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
||||
return account.RaceNameCanonicalKey(strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
).Replace(strings.ToLower(raceName.String())))
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Package redisstate defines the frozen Redis logical keyspace and pagination
|
||||
// helpers used by future User Service storage adapters.
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
const defaultPrefix = "user:"
|
||||
|
||||
// Keyspace builds the frozen Redis logical keys used by future storage
|
||||
// adapters. The package intentionally exposes key construction only and does
|
||||
// not depend on any Redis client.
|
||||
type Keyspace struct {
|
||||
// Prefix stores the namespace prefix applied to every key. The zero value
|
||||
// uses `user:`.
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// Account returns the primary user-account key for userID.
|
||||
func (k Keyspace) Account(userID common.UserID) string {
|
||||
return k.prefix() + "account:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// EmailLookup returns the exact normalized e-mail lookup key.
|
||||
func (k Keyspace) EmailLookup(email common.Email) string {
|
||||
return k.prefix() + "lookup:email:" + encodeKeyComponent(email.String())
|
||||
}
|
||||
|
||||
// RaceNameLookup returns the exact stored race-name lookup key.
|
||||
func (k Keyspace) RaceNameLookup(raceName common.RaceName) string {
|
||||
return k.prefix() + "lookup:race-name:" + encodeKeyComponent(raceName.String())
|
||||
}
|
||||
|
||||
// RaceNameReservation returns the replaceable canonical race-name reservation
|
||||
// key.
|
||||
func (k Keyspace) RaceNameReservation(key account.RaceNameCanonicalKey) string {
|
||||
return k.prefix() + "reservation:race-name:" + encodeKeyComponent(key.String())
|
||||
}
|
||||
|
||||
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
|
||||
func (k Keyspace) BlockedEmailSubject(email common.Email) string {
|
||||
return k.prefix() + "blocked-email:" + encodeKeyComponent(email.String())
|
||||
}
|
||||
|
||||
// EntitlementRecord returns the primary entitlement history-record key.
|
||||
func (k Keyspace) EntitlementRecord(recordID entitlement.EntitlementRecordID) string {
|
||||
return k.prefix() + "entitlement:record:" + encodeKeyComponent(recordID.String())
|
||||
}
|
||||
|
||||
// EntitlementHistory returns the per-user entitlement-history index key.
|
||||
func (k Keyspace) EntitlementHistory(userID common.UserID) string {
|
||||
return k.prefix() + "entitlement:history:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// EntitlementSnapshot returns the current entitlement-snapshot key.
|
||||
func (k Keyspace) EntitlementSnapshot(userID common.UserID) string {
|
||||
return k.prefix() + "entitlement:snapshot:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// SanctionRecord returns the primary sanction history-record key.
|
||||
func (k Keyspace) SanctionRecord(recordID policy.SanctionRecordID) string {
|
||||
return k.prefix() + "sanction:record:" + encodeKeyComponent(recordID.String())
|
||||
}
|
||||
|
||||
// SanctionHistory returns the per-user sanction-history index key.
|
||||
func (k Keyspace) SanctionHistory(userID common.UserID) string {
|
||||
return k.prefix() + "sanction:history:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// ActiveSanction returns the per-user active-sanction slot for one sanction
|
||||
// code. The slot guarantees at most one active sanction per `user_id +
|
||||
// sanction_code`.
|
||||
func (k Keyspace) ActiveSanction(userID common.UserID, code policy.SanctionCode) string {
|
||||
return k.prefix() + "sanction:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// LimitRecord returns the primary limit history-record key.
|
||||
func (k Keyspace) LimitRecord(recordID policy.LimitRecordID) string {
|
||||
return k.prefix() + "limit:record:" + encodeKeyComponent(recordID.String())
|
||||
}
|
||||
|
||||
// LimitHistory returns the per-user limit-history index key.
|
||||
func (k Keyspace) LimitHistory(userID common.UserID) string {
|
||||
return k.prefix() + "limit:history:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// ActiveLimit returns the per-user active-limit slot for one limit code. The
|
||||
// slot guarantees at most one active limit per `user_id + limit_code`.
|
||||
func (k Keyspace) ActiveLimit(userID common.UserID, code policy.LimitCode) string {
|
||||
return k.prefix() + "limit:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// CreatedAtIndex returns the deterministic newest-first user-ordering index.
|
||||
func (k Keyspace) CreatedAtIndex() string {
|
||||
return k.prefix() + "index:created-at"
|
||||
}
|
||||
|
||||
// PaidStateIndex returns the coarse free-versus-paid index key.
|
||||
func (k Keyspace) PaidStateIndex(state entitlement.PaidState) string {
|
||||
return k.prefix() + "index:paid-state:" + encodeKeyComponent(string(state))
|
||||
}
|
||||
|
||||
// FinitePaidExpiryIndex returns the finite paid-expiry index key. Lifetime
|
||||
// plans intentionally do not participate in this index.
|
||||
func (k Keyspace) FinitePaidExpiryIndex() string {
|
||||
return k.prefix() + "index:paid-expiry:finite"
|
||||
}
|
||||
|
||||
// DeclaredCountryIndex returns the current declared-country reverse-lookup
|
||||
// index key.
|
||||
func (k Keyspace) DeclaredCountryIndex(code common.CountryCode) string {
|
||||
return k.prefix() + "index:declared-country:" + encodeKeyComponent(code.String())
|
||||
}
|
||||
|
||||
// ActiveSanctionCodeIndex returns the reverse-lookup index key for users with
|
||||
// an active sanction code.
|
||||
func (k Keyspace) ActiveSanctionCodeIndex(code policy.SanctionCode) string {
|
||||
return k.prefix() + "index:active-sanction:" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// ActiveLimitCodeIndex returns the reverse-lookup index key for users with an
|
||||
// active limit code.
|
||||
func (k Keyspace) ActiveLimitCodeIndex(code policy.LimitCode) string {
|
||||
return k.prefix() + "index:active-limit:" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// EligibilityMarkerIndex returns the reverse-lookup index key for one derived
|
||||
// eligibility marker boolean.
|
||||
func (k Keyspace) EligibilityMarkerIndex(marker policy.EligibilityMarker, value bool) string {
|
||||
return fmt.Sprintf("%sindex:eligibility:%s:%t", k.prefix(), encodeKeyComponent(string(marker)), value)
|
||||
}
|
||||
|
||||
// CreatedAtScore returns the frozen ZSET score representation for created-at
|
||||
// ordering and deterministic pagination.
|
||||
func CreatedAtScore(createdAt time.Time) float64 {
|
||||
return float64(createdAt.UTC().UnixMicro())
|
||||
}
|
||||
|
||||
// ExpiryScore returns the frozen ZSET score representation for finite paid
|
||||
// expiry ordering.
|
||||
func ExpiryScore(expiresAt time.Time) float64 {
|
||||
return float64(expiresAt.UTC().UnixMicro())
|
||||
}
|
||||
|
||||
// PageCursor identifies the last seen `(created_at, user_id)` tuple used by
|
||||
// deterministic newest-first pagination.
|
||||
type PageCursor struct {
|
||||
// CreatedAt stores the created-at component of the last seen row.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UserID stores the user-id tiebreaker component of the last seen row.
|
||||
UserID common.UserID
|
||||
}
|
||||
|
||||
// Validate reports whether PageCursor contains a complete cursor tuple.
|
||||
func (cursor PageCursor) Validate() error {
|
||||
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cursor.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("page cursor user id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComparePageOrder compares two listing positions using the frozen ordering:
|
||||
// `created_at desc`, then `user_id desc`.
|
||||
func ComparePageOrder(left PageCursor, right PageCursor) int {
|
||||
switch {
|
||||
case left.CreatedAt.After(right.CreatedAt):
|
||||
return -1
|
||||
case left.CreatedAt.Before(right.CreatedAt):
|
||||
return 1
|
||||
default:
|
||||
return -strings.Compare(left.UserID.String(), right.UserID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (k Keyspace) prefix() string {
|
||||
prefix := strings.TrimSpace(k.Prefix)
|
||||
if prefix == "" {
|
||||
return defaultPrefix
|
||||
}
|
||||
|
||||
return prefix
|
||||
}
|
||||
|
||||
func encodeKeyComponent(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeyspaceBuildsStableKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyspace := Keyspace{Prefix: "custom:"}
|
||||
|
||||
require.Equal(t, "custom:account:dXNlci0xMjM", keyspace.Account(common.UserID("user-123")))
|
||||
require.Equal(t, "custom:lookup:email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.EmailLookup(common.Email("pilot@example.com")))
|
||||
require.Equal(t, "custom:lookup:race-name:UGlsb3QgTm92YQ", keyspace.RaceNameLookup(common.RaceName("Pilot Nova")))
|
||||
require.Equal(t, "custom:reservation:race-name:cGlsb3Qtbm92YQ", keyspace.RaceNameReservation(account.RaceNameCanonicalKey("pilot-nova")))
|
||||
require.Equal(t, "custom:blocked-email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.BlockedEmailSubject(common.Email("pilot@example.com")))
|
||||
require.Equal(t, "custom:entitlement:record:ZW50aXRsZW1lbnQtMTIz", keyspace.EntitlementRecord(entitlement.EntitlementRecordID("entitlement-123")))
|
||||
require.Equal(t, "custom:sanction:record:c2FuY3Rpb24tMQ", keyspace.SanctionRecord(policy.SanctionRecordID("sanction-1")))
|
||||
require.Equal(t, "custom:limit:record:bGltaXQtMQ", keyspace.LimitRecord(policy.LimitRecordID("limit-1")))
|
||||
require.Equal(t, "custom:sanction:active:dXNlci0xMjM:bG9naW5fYmxvY2s", keyspace.ActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock))
|
||||
require.Equal(t, "custom:limit:active:dXNlci0xMjM:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames))
|
||||
require.Equal(t, "custom:index:created-at", keyspace.CreatedAtIndex())
|
||||
require.Equal(t, "custom:index:paid-state:cGFpZA", keyspace.PaidStateIndex(entitlement.PaidStatePaid))
|
||||
require.Equal(t, "custom:index:paid-expiry:finite", keyspace.FinitePaidExpiryIndex())
|
||||
require.Equal(t, "custom:index:declared-country:REU", keyspace.DeclaredCountryIndex(common.CountryCode("DE")))
|
||||
require.Equal(t, "custom:index:active-sanction:bG9naW5fYmxvY2s", keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock))
|
||||
require.Equal(t, "custom:index:active-limit:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames))
|
||||
require.Equal(t, "custom:index:eligibility:Y2FuX2xvZ2lu:true", keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true))
|
||||
}
|
||||
|
||||
func TestComparePageOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newer := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-200")}
|
||||
older := PageCursor{CreatedAt: time.Unix(10, 0).UTC(), UserID: common.UserID("user-100")}
|
||||
sameTimeHigherUserID := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-300")}
|
||||
|
||||
require.Negative(t, ComparePageOrder(newer, older))
|
||||
require.Positive(t, ComparePageOrder(older, newer))
|
||||
require.Negative(t, ComparePageOrder(sameTimeHigherUserID, newer))
|
||||
}
|
||||
|
||||
func TestScoresUseUnixMicro(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
value := time.Unix(1_775_240_000, 123_000).UTC()
|
||||
want := float64(value.UnixMicro())
|
||||
|
||||
require.Equal(t, want, CreatedAtScore(value))
|
||||
require.Equal(t, want, ExpiryScore(value))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPageTokenFiltersMismatch reports that a supplied page token was created
|
||||
// for a different normalized filter set.
|
||||
ErrPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
|
||||
)
|
||||
|
||||
// UserListFilters stores the frozen admin-listing filter set that becomes part
|
||||
// of the opaque page token fingerprint.
|
||||
type UserListFilters struct {
|
||||
// PaidState stores the coarse free-versus-paid filter.
|
||||
PaidState entitlement.PaidState
|
||||
|
||||
// PaidExpiresBefore stores the optional finite-paid expiry upper bound.
|
||||
PaidExpiresBefore *time.Time
|
||||
|
||||
// PaidExpiresAfter stores the optional finite-paid expiry lower bound.
|
||||
PaidExpiresAfter *time.Time
|
||||
|
||||
// DeclaredCountry stores the optional declared-country filter.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// SanctionCode stores the optional active-sanction filter.
|
||||
SanctionCode policy.SanctionCode
|
||||
|
||||
// LimitCode stores the optional active-limit filter.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// CanLogin stores the optional login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
// CanCreatePrivateGame stores the optional private-game-create eligibility
|
||||
// filter.
|
||||
CanCreatePrivateGame *bool
|
||||
|
||||
// CanJoinGame stores the optional join-game eligibility filter.
|
||||
CanJoinGame *bool
|
||||
}
|
||||
|
||||
// Validate reports whether UserListFilters is structurally valid.
|
||||
func (filters UserListFilters) Validate() error {
|
||||
if !filters.PaidState.IsKnown() {
|
||||
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
|
||||
}
|
||||
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
|
||||
return fmt.Errorf("paid expires before must not be zero")
|
||||
}
|
||||
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
|
||||
return fmt.Errorf("paid expires after must not be zero")
|
||||
}
|
||||
if !filters.DeclaredCountry.IsZero() {
|
||||
if err := filters.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("declared country: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
|
||||
}
|
||||
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
|
||||
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePageToken encodes cursor and filters into the frozen opaque page token
|
||||
// format.
|
||||
func EncodePageToken(cursor PageCursor, filters UserListFilters) (string, error) {
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return "", fmt.Errorf("encode page token: %w", err)
|
||||
}
|
||||
fingerprint, err := normalizeFilters(filters)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode page token: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(pageTokenPayload{
|
||||
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
UserID: cursor.UserID.String(),
|
||||
Filters: fingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode page token: %w", err)
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
// DecodePageToken decodes raw into the frozen page cursor and verifies that
|
||||
// the embedded normalized filter set matches expectedFilters.
|
||||
func DecodePageToken(raw string, expectedFilters UserListFilters) (PageCursor, error) {
|
||||
fingerprint, err := normalizeFilters(expectedFilters)
|
||||
if err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
|
||||
var token pageTokenPayload
|
||||
if err := json.Unmarshal(payload, &token); err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
if token.Filters != fingerprint {
|
||||
return PageCursor{}, ErrPageTokenFiltersMismatch
|
||||
}
|
||||
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt)
|
||||
if err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err)
|
||||
}
|
||||
|
||||
cursor := PageCursor{
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UserID: common.UserID(token.UserID),
|
||||
}
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
type pageTokenPayload struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UserID string `json:"user_id"`
|
||||
Filters normalizedFilterPayload `json:"filters"`
|
||||
}
|
||||
|
||||
type normalizedFilterPayload struct {
|
||||
PaidState string `json:"paid_state,omitempty"`
|
||||
PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"`
|
||||
PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"`
|
||||
DeclaredCountry string `json:"declared_country,omitempty"`
|
||||
SanctionCode string `json:"sanction_code,omitempty"`
|
||||
LimitCode string `json:"limit_code,omitempty"`
|
||||
CanLogin string `json:"can_login,omitempty"`
|
||||
CanCreatePrivateGame string `json:"can_create_private_game,omitempty"`
|
||||
CanJoinGame string `json:"can_join_game,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeFilters(filters UserListFilters) (normalizedFilterPayload, error) {
|
||||
if err := filters.Validate(); err != nil {
|
||||
return normalizedFilterPayload{}, err
|
||||
}
|
||||
|
||||
return normalizedFilterPayload{
|
||||
PaidState: string(filters.PaidState),
|
||||
PaidExpiresBeforeUTC: formatOptionalTime(filters.PaidExpiresBefore),
|
||||
PaidExpiresAfterUTC: formatOptionalTime(filters.PaidExpiresAfter),
|
||||
DeclaredCountry: filters.DeclaredCountry.String(),
|
||||
SanctionCode: string(filters.SanctionCode),
|
||||
LimitCode: string(filters.LimitCode),
|
||||
CanLogin: formatOptionalBool(filters.CanLogin),
|
||||
CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame),
|
||||
CanJoinGame: formatOptionalBool(filters.CanJoinGame),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatOptionalTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func formatOptionalBool(value *bool) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if *value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncodeDecodePageToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := time.Unix(1_775_250_000, 0).UTC()
|
||||
after := time.Unix(1_775_240_000, 0).UTC()
|
||||
canLogin := true
|
||||
canCreate := false
|
||||
canJoin := true
|
||||
|
||||
filters := UserListFilters{
|
||||
PaidState: entitlement.PaidStatePaid,
|
||||
PaidExpiresBefore: &before,
|
||||
PaidExpiresAfter: &after,
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
CanLogin: &canLogin,
|
||||
CanCreatePrivateGame: &canCreate,
|
||||
CanJoinGame: &canJoin,
|
||||
}
|
||||
cursor := PageCursor{
|
||||
CreatedAt: time.Unix(1_775_240_100, 987_000_000).UTC(),
|
||||
UserID: common.UserID("user-123"),
|
||||
}
|
||||
|
||||
token, err := EncodePageToken(cursor, filters)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := DecodePageToken(token, filters)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cursor, decoded)
|
||||
}
|
||||
|
||||
func TestDecodePageTokenFilterMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cursor := PageCursor{
|
||||
CreatedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
UserID: common.UserID("user-123"),
|
||||
}
|
||||
filters := UserListFilters{
|
||||
PaidState: entitlement.PaidStatePaid,
|
||||
}
|
||||
|
||||
token, err := EncodePageToken(cursor, filters)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = DecodePageToken(token, UserListFilters{PaidState: entitlement.PaidStateFree})
|
||||
require.ErrorIs(t, err, ErrPageTokenFiltersMismatch)
|
||||
}
|
||||
|
||||
func TestDecodePageTokenRejectsInvalidInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodePageToken("%%%not-base64%%%", UserListFilters{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package adminapi exposes the optional private admin HTTP listener used for
|
||||
// operational endpoints such as Prometheus metrics.
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"galaxy/user/internal/config"
|
||||
)
|
||||
|
||||
// Server owns the optional admin HTTP listener exposed by the user service.
|
||||
type Server struct {
|
||||
cfg config.AdminHTTPConfig
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs an admin HTTP server for cfg and handler.
|
||||
func NewServer(cfg config.AdminHTTPConfig, handler http.Handler, logger *slog.Logger) *Server {
|
||||
if handler == nil {
|
||||
handler = http.NotFoundHandler()
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /metrics", handler)
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: mux,
|
||||
logger: logger.With("component", "admin_http"),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the admin listener should run.
|
||||
func (server *Server) Enabled() bool {
|
||||
return server != nil && server.cfg.Addr != ""
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the admin HTTP surface until
|
||||
// Shutdown closes the server. A disabled admin server returns when ctx is
|
||||
// canceled.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run admin HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !server.Enabled() {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run admin HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("admin HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(shutdownDone)
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.ReadTimeout)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
<-shutdownDone
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("admin HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run admin HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the admin HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown admin HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown admin HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServerRunDisabledWaitsForContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(config.AdminHTTPConfig{}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("disabled admin server must not serve requests")
|
||||
}), nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Run(ctx)
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("disabled admin server did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRunServesMetricsOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(config.AdminHTTPConfig{
|
||||
Addr: "127.0.0.1:0",
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
IdleTimeout: time.Minute,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("sample_metric 1\n"))
|
||||
}), nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Run(ctx)
|
||||
}()
|
||||
|
||||
addr := waitForListener(t, server)
|
||||
|
||||
metricsResponse, err := http.Get("http://" + addr + "/metrics")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = metricsResponse.Body.Close() })
|
||||
require.Equal(t, http.StatusOK, metricsResponse.StatusCode)
|
||||
|
||||
rootResponse, err := http.Get("http://" + addr + "/")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = rootResponse.Body.Close() })
|
||||
require.Equal(t, http.StatusNotFound, rootResponse.StatusCode)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("admin server did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForListener(t *testing.T, server *Server) string {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
server.stateMu.RLock()
|
||||
listener := server.listener
|
||||
server.stateMu.RUnlock()
|
||||
if listener != nil {
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatal("admin server listener did not start")
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type getUserByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type getUserByRaceNameRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
func handleGetUserByID(useCase GetUserByIDUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByIDInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUserByEmail(useCase GetUserByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request getUserByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByEmailInput{
|
||||
Email: request.Email,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request getUserByRaceNameRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByRaceNameInput{
|
||||
RaceName: request.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleListUsers(useCase ListUsersUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
input, err := buildListUsersInput(c)
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, input)
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func buildListUsersInput(c *gin.Context) (adminusers.ListUsersInput, error) {
|
||||
pageSize, err := parseOptionalPageSize(c, "page_size")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
pageToken, err := parseOptionalPageToken(c, "page_token")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
paidExpiresBefore, err := parseOptionalRFC3339Query(c, "paid_expires_before")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
paidExpiresAfter, err := parseOptionalRFC3339Query(c, "paid_expires_after")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
canLogin, err := parseOptionalBoolQuery(c, "can_login")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
canCreatePrivateGame, err := parseOptionalBoolQuery(c, "can_create_private_game")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
canJoinGame, err := parseOptionalBoolQuery(c, "can_join_game")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
|
||||
return adminusers.ListUsersInput{
|
||||
PageSize: pageSize,
|
||||
PageToken: pageToken,
|
||||
PaidState: c.Query("paid_state"),
|
||||
PaidExpiresBefore: paidExpiresBefore,
|
||||
PaidExpiresAfter: paidExpiresAfter,
|
||||
DeclaredCountry: c.Query("declared_country"),
|
||||
SanctionCode: c.Query("sanction_code"),
|
||||
LimitCode: c.Query("limit_code"),
|
||||
CanLogin: canLogin,
|
||||
CanCreatePrivateGame: canCreatePrivateGame,
|
||||
CanJoinGame: canJoinGame,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseOptionalPageSize(c *gin.Context, name string) (int, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value < 1 || value > 200 {
|
||||
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseOptionalPageToken(c *gin.Context, name string) (string, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return "", nil
|
||||
}
|
||||
if strings.TrimSpace(raw) != raw {
|
||||
return "", shared.InvalidRequest("page_token must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func parseOptionalRFC3339Query(c *gin.Context, name string) (*time.Time, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, shared.InvalidRequest(name + " must be a valid RFC 3339 timestamp")
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func parseOptionalBoolQuery(c *gin.Context, name string) (*bool, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, shared.InvalidRequest(name + " must be a valid boolean")
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/service/accountview"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAdminReadHandlersSuccessCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, Dependencies{
|
||||
GetUserByID: getUserByIDFunc(func(_ context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
|
||||
require.Equal(t, "user-123", input.UserID)
|
||||
return adminusers.LookupResult{User: sampleAccountView()}, nil
|
||||
}),
|
||||
GetUserByEmail: getUserByEmailFunc(func(_ context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
|
||||
require.Equal(t, "pilot@example.com", input.Email)
|
||||
return adminusers.LookupResult{User: sampleAccountView()}, nil
|
||||
}),
|
||||
GetUserByRaceName: getUserByRaceNameFunc(func(_ context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
|
||||
require.Equal(t, "Pilot Nova", input.RaceName)
|
||||
return adminusers.LookupResult{User: sampleAccountView()}, nil
|
||||
}),
|
||||
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
|
||||
require.Equal(t, 2, input.PageSize)
|
||||
require.Equal(t, "cursor-1", input.PageToken)
|
||||
require.Equal(t, "paid", input.PaidState)
|
||||
require.Equal(t, "DE", input.DeclaredCountry)
|
||||
require.Equal(t, "login_block", input.SanctionCode)
|
||||
require.Equal(t, "max_owned_private_games", input.LimitCode)
|
||||
require.NotNil(t, input.PaidExpiresBefore)
|
||||
require.NotNil(t, input.PaidExpiresAfter)
|
||||
require.NotNil(t, input.CanLogin)
|
||||
require.NotNil(t, input.CanCreatePrivateGame)
|
||||
require.NotNil(t, input.CanJoinGame)
|
||||
require.False(t, *input.CanLogin)
|
||||
require.True(t, *input.CanCreatePrivateGame)
|
||||
require.True(t, *input.CanJoinGame)
|
||||
require.Equal(t, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC), input.PaidExpiresBefore.UTC())
|
||||
require.Equal(t, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC), input.PaidExpiresAfter.UTC())
|
||||
|
||||
other := sampleAccountView()
|
||||
other.UserID = "user-234"
|
||||
other.Email = "second@example.com"
|
||||
other.RaceName = "Second Pilot"
|
||||
|
||||
return adminusers.ListUsersResult{
|
||||
Items: []accountview.AccountView{sampleAccountView(), other},
|
||||
NextPageToken: "cursor-2",
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "get user by id",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users/user-123",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by email",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-email",
|
||||
body: `{"email":"pilot@example.com"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by race name",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-race-name",
|
||||
body: `{"race_name":"Pilot Nova"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?page_size=2&page_token=cursor-1&paid_state=paid&paid_expires_before=2026-04-10T12:00:00Z&paid_expires_after=2026-04-01T12:00:00Z&declared_country=DE&sanction_code=login_block&limit_code=max_owned_private_games&can_login=false&can_create_private_game=true&can_join_game=true",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"items":[{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},{"user_id":"user-234","email":"second@example.com","race_name":"Second Pilot","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}],"next_page_token":"cursor-2"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var body *bytes.Buffer
|
||||
if tt.body != "" {
|
||||
body = bytes.NewBufferString(tt.body)
|
||||
} else {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(tt.method, tt.path, body)
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, tt.wantStatus, recorder.Code)
|
||||
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReadHandlersErrorCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, Dependencies{
|
||||
GetUserByID: getUserByIDFunc(func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, shared.SubjectNotFound()
|
||||
}),
|
||||
GetUserByEmail: getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, shared.SubjectNotFound()
|
||||
}),
|
||||
GetUserByRaceName: getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, shared.SubjectNotFound()
|
||||
}),
|
||||
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
|
||||
return adminusers.ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
|
||||
}),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "get user by id not found",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users/user-missing",
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by email malformed json",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-email",
|
||||
body: `{"email":"pilot@example.com","extra":true}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by race name not found",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-race-name",
|
||||
body: `{"race_name":"Missing Pilot"}`,
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid page size",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?page_size=201",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"page_size must be between 1 and 200"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid timestamp",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?paid_expires_before=not-a-time",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"paid_expires_before must be a valid RFC 3339 timestamp"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid boolean",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?can_login=maybe",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"can_login must be a valid boolean"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid page token",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?page_token=cursor-1",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var body *bytes.Buffer
|
||||
if tt.body != "" {
|
||||
body = bytes.NewBufferString(tt.body)
|
||||
} else {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(tt.method, tt.path, body)
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, tt.wantStatus, recorder.Code)
|
||||
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/logging"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/geosync"
|
||||
"galaxy/user/internal/service/lobbyeligibility"
|
||||
"galaxy/user/internal/service/policysvc"
|
||||
"galaxy/user/internal/service/selfservice"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const internalHTTPServiceName = "galaxy-user-internal"
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type resolveByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type resolveByEmailResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
BlockReasonCode string `json:"block_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
type existsByUserIDResponse struct {
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
type ensureByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
RegistrationContext *ensureRegistrationContextDTO `json:"registration_context"`
|
||||
}
|
||||
|
||||
type ensureRegistrationContextDTO struct {
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
}
|
||||
|
||||
type ensureByEmailResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
BlockReasonCode string `json:"block_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
type blockByUserIDRequest struct {
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type blockByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type blockResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type getMyAccountResponse struct {
|
||||
Account selfservice.AccountView `json:"account"`
|
||||
}
|
||||
|
||||
type updateMyProfileRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
type updateMySettingsRequest struct {
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
}
|
||||
|
||||
type syncDeclaredCountryRequest struct {
|
||||
DeclaredCountry string `json:"declared_country"`
|
||||
}
|
||||
|
||||
type syncDeclaredCountryResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeclaredCountry string `json:"declared_country"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type actorDTO struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type grantEntitlementRequest struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
}
|
||||
|
||||
type extendEntitlementRequest struct {
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
}
|
||||
|
||||
type revokeEntitlementRequest struct {
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type applySanctionRequest struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
Scope string `json:"scope"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
AppliedAt string `json:"applied_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type removeSanctionRequest struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type setLimitRequest struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int `json:"value"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
AppliedAt string `json:"applied_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type removeLimitRequest struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type entitlementSnapshotResponse struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
IsPaid bool `json:"is_paid"`
|
||||
Source string `json:"source"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type entitlementCommandResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Entitlement entitlementSnapshotResponse `json:"entitlement"`
|
||||
}
|
||||
|
||||
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedDeps, err := normalizeDependencies(deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configureGinModeOnce.Do(func() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
})
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
|
||||
engine.Use(withObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
|
||||
engine.POST("/api/v1/internal/user-resolutions/by-email", handleResolveByEmail(normalizedDeps.ResolveByEmail, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id/exists", handleExistsByUserID(normalizedDeps.ExistsByUserID, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/ensure-by-email", handleEnsureByEmail(normalizedDeps.EnsureByEmail, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/block", handleBlockByUserID(normalizedDeps.BlockByUserID, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/user-blocks/by-email", handleBlockByEmail(normalizedDeps.BlockByEmail, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id/account", handleGetMyAccount(normalizedDeps.GetMyAccount, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/profile", handleUpdateMyProfile(normalizedDeps.UpdateMyProfile, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/settings", handleUpdateMySettings(normalizedDeps.UpdateMySettings, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id", handleGetUserByID(normalizedDeps.GetUserByID, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/user-lookups/by-email", handleGetUserByEmail(normalizedDeps.GetUserByEmail, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/user-lookups/by-race-name", handleGetUserByRaceName(normalizedDeps.GetUserByRaceName, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users", handleListUsers(normalizedDeps.ListUsers, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id/eligibility", handleGetUserEligibility(normalizedDeps.GetUserEligibility, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/declared-country/sync", handleSyncDeclaredCountry(normalizedDeps.SyncDeclaredCountry, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/entitlements/grant", handleGrantEntitlement(normalizedDeps.GrantEntitlement, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/entitlements/extend", handleExtendEntitlement(normalizedDeps.ExtendEntitlement, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/entitlements/revoke", handleRevokeEntitlement(normalizedDeps.RevokeEntitlement, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/sanctions/apply", handleApplySanction(normalizedDeps.ApplySanction, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/sanctions/remove", handleRemoveSanction(normalizedDeps.RemoveSanction, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/limits/set", handleSetLimit(normalizedDeps.SetLimit, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/limits/remove", handleRemoveLimit(normalizedDeps.RemoveLimit, cfg.RequestTimeout))
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func handleResolveByEmail(useCase ResolveByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request resolveByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.ResolveByEmailInput{
|
||||
Email: request.Email,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resolveByEmailResponse{
|
||||
Kind: result.Kind,
|
||||
UserID: result.UserID,
|
||||
BlockReasonCode: result.BlockReasonCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleExistsByUserID(useCase ExistsByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.ExistsByUserIDInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, existsByUserIDResponse{Exists: result.Exists})
|
||||
}
|
||||
}
|
||||
|
||||
func handleEnsureByEmail(useCase EnsureByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request ensureByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
if request.RegistrationContext == nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest("registration_context must be present")))
|
||||
return
|
||||
}
|
||||
|
||||
var registrationContext *authdirectory.RegistrationContext
|
||||
registrationContext = &authdirectory.RegistrationContext{
|
||||
PreferredLanguage: request.RegistrationContext.PreferredLanguage,
|
||||
TimeZone: request.RegistrationContext.TimeZone,
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.EnsureByEmailInput{
|
||||
Email: request.Email,
|
||||
RegistrationContext: registrationContext,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ensureByEmailResponse{
|
||||
Outcome: result.Outcome,
|
||||
UserID: result.UserID,
|
||||
BlockReasonCode: result.BlockReasonCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleBlockByUserID(useCase BlockByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request blockByUserIDRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.BlockByUserIDInput{
|
||||
UserID: c.Param("user_id"),
|
||||
ReasonCode: request.ReasonCode,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, blockResponse{
|
||||
Outcome: result.Outcome,
|
||||
UserID: result.UserID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleBlockByEmail(useCase BlockByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request blockByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.BlockByEmailInput{
|
||||
Email: request.Email,
|
||||
ReasonCode: request.ReasonCode,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, blockResponse{
|
||||
Outcome: result.Outcome,
|
||||
UserID: result.UserID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetMyAccount(useCase GetMyAccountUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, selfservice.GetMyAccountInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getMyAccountResponse{
|
||||
Account: result.Account,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateMyProfile(useCase UpdateMyProfileUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request updateMyProfileRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, selfservice.UpdateMyProfileInput{
|
||||
UserID: c.Param("user_id"),
|
||||
RaceName: request.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getMyAccountResponse{
|
||||
Account: result.Account,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateMySettings(useCase UpdateMySettingsUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request updateMySettingsRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, selfservice.UpdateMySettingsInput{
|
||||
UserID: c.Param("user_id"),
|
||||
PreferredLanguage: request.PreferredLanguage,
|
||||
TimeZone: request.TimeZone,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getMyAccountResponse{
|
||||
Account: result.Account,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUserEligibility(useCase GetUserEligibilityUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, lobbyeligibility.GetUserEligibilityInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSyncDeclaredCountry(useCase SyncDeclaredCountryUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request syncDeclaredCountryRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, geosync.SyncDeclaredCountryInput{
|
||||
UserID: c.Param("user_id"),
|
||||
DeclaredCountry: request.DeclaredCountry,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, syncDeclaredCountryResponse{
|
||||
UserID: result.UserID,
|
||||
DeclaredCountry: result.DeclaredCountry,
|
||||
UpdatedAt: result.UpdatedAt.UTC(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGrantEntitlement(useCase GrantEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request grantEntitlementRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, entitlementsvc.GrantInput{
|
||||
UserID: c.Param("user_id"),
|
||||
PlanCode: request.PlanCode,
|
||||
Source: request.Source,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: entitlementsvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
StartsAt: request.StartsAt,
|
||||
EndsAt: request.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleExtendEntitlement(useCase ExtendEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request extendEntitlementRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, entitlementsvc.ExtendInput{
|
||||
UserID: c.Param("user_id"),
|
||||
Source: request.Source,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: entitlementsvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
EndsAt: request.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleRevokeEntitlement(useCase RevokeEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request revokeEntitlementRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, entitlementsvc.RevokeInput{
|
||||
UserID: c.Param("user_id"),
|
||||
Source: request.Source,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: entitlementsvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleApplySanction(useCase ApplySanctionUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request applySanctionRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.ApplySanctionInput{
|
||||
UserID: c.Param("user_id"),
|
||||
SanctionCode: request.SanctionCode,
|
||||
Scope: request.Scope,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
AppliedAt: request.AppliedAt,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveSanction(useCase RemoveSanctionUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request removeSanctionRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.RemoveSanctionInput{
|
||||
UserID: c.Param("user_id"),
|
||||
SanctionCode: request.SanctionCode,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetLimit(useCase SetLimitUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request setLimitRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.SetLimitInput{
|
||||
UserID: c.Param("user_id"),
|
||||
LimitCode: request.LimitCode,
|
||||
Value: request.Value,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
AppliedAt: request.AppliedAt,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveLimit(useCase RemoveLimitUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request removeLimitRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.RemoveLimitInput{
|
||||
UserID: c.Param("user_id"),
|
||||
LimitCode: request.LimitCode,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
|
||||
switch {
|
||||
case deps.ResolveByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("resolve-by-email use case must not be nil")
|
||||
case deps.EnsureByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("ensure-by-email use case must not be nil")
|
||||
case deps.ExistsByUserID == nil:
|
||||
return Dependencies{}, fmt.Errorf("exists-by-user-id use case must not be nil")
|
||||
case deps.BlockByUserID == nil:
|
||||
return Dependencies{}, fmt.Errorf("block-by-user-id use case must not be nil")
|
||||
case deps.BlockByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("block-by-email use case must not be nil")
|
||||
case deps.GetMyAccount == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-my-account use case must not be nil")
|
||||
case deps.UpdateMyProfile == nil:
|
||||
return Dependencies{}, fmt.Errorf("update-my-profile use case must not be nil")
|
||||
case deps.UpdateMySettings == nil:
|
||||
return Dependencies{}, fmt.Errorf("update-my-settings use case must not be nil")
|
||||
case deps.GetUserByID == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-by-id use case must not be nil")
|
||||
case deps.GetUserByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-by-email use case must not be nil")
|
||||
case deps.GetUserByRaceName == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-by-race-name use case must not be nil")
|
||||
case deps.ListUsers == nil:
|
||||
return Dependencies{}, fmt.Errorf("list-users use case must not be nil")
|
||||
case deps.GetUserEligibility == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-eligibility use case must not be nil")
|
||||
case deps.SyncDeclaredCountry == nil:
|
||||
return Dependencies{}, fmt.Errorf("sync-declared-country use case must not be nil")
|
||||
case deps.GrantEntitlement == nil:
|
||||
return Dependencies{}, fmt.Errorf("grant-entitlement use case must not be nil")
|
||||
case deps.ExtendEntitlement == nil:
|
||||
return Dependencies{}, fmt.Errorf("extend-entitlement use case must not be nil")
|
||||
case deps.RevokeEntitlement == nil:
|
||||
return Dependencies{}, fmt.Errorf("revoke-entitlement use case must not be nil")
|
||||
case deps.ApplySanction == nil:
|
||||
return Dependencies{}, fmt.Errorf("apply-sanction use case must not be nil")
|
||||
case deps.RemoveSanction == nil:
|
||||
return Dependencies{}, fmt.Errorf("remove-sanction use case must not be nil")
|
||||
case deps.SetLimit == nil:
|
||||
return Dependencies{}, fmt.Errorf("set-limit use case must not be nil")
|
||||
case deps.RemoveLimit == nil:
|
||||
return Dependencies{}, fmt.Errorf("remove-limit use case must not be nil")
|
||||
default:
|
||||
if deps.Logger == nil {
|
||||
deps.Logger = slog.Default()
|
||||
}
|
||||
return deps, nil
|
||||
}
|
||||
}
|
||||
|
||||
func entitlementCommandResponseFromResult(result entitlementsvc.CommandResult) entitlementCommandResponse {
|
||||
response := entitlementCommandResponse{
|
||||
UserID: result.UserID,
|
||||
Entitlement: entitlementSnapshotResponse{
|
||||
PlanCode: string(result.Entitlement.PlanCode),
|
||||
IsPaid: result.Entitlement.IsPaid,
|
||||
Source: result.Entitlement.Source.String(),
|
||||
Actor: actorDTO{Type: result.Entitlement.Actor.Type.String(), ID: result.Entitlement.Actor.ID.String()},
|
||||
ReasonCode: result.Entitlement.ReasonCode.String(),
|
||||
StartsAt: result.Entitlement.StartsAt.UTC(),
|
||||
UpdatedAt: result.Entitlement.UpdatedAt.UTC(),
|
||||
},
|
||||
}
|
||||
if result.Entitlement.EndsAt != nil {
|
||||
value := result.Entitlement.EndsAt.UTC()
|
||||
response.Entitlement.EndsAt = &value
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
|
||||
options := []otelgin.Option{}
|
||||
if runtime != nil {
|
||||
options = append(
|
||||
options,
|
||||
otelgin.WithTracerProvider(runtime.TracerProvider()),
|
||||
otelgin.WithMeterProvider(runtime.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelgin.Middleware(internalHTTPServiceName, options...)
|
||||
}
|
||||
|
||||
func withObservability(logger *slog.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
c.Next()
|
||||
|
||||
statusCode := c.Writer.Status()
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
errorCode, _ := c.Get(internalErrorCodeContextKey)
|
||||
errorCodeValue, _ := errorCode.(string)
|
||||
outcome := outcomeFromStatusCode(statusCode)
|
||||
duration := time.Since(startedAt)
|
||||
|
||||
attrs := []any{
|
||||
"transport", "http",
|
||||
"route", route,
|
||||
"method", c.Request.Method,
|
||||
"status_code", statusCode,
|
||||
"duration_ms", float64(duration.Microseconds()) / 1000,
|
||||
"edge_outcome", string(outcome),
|
||||
}
|
||||
if errorCodeValue != "" {
|
||||
attrs = append(attrs, "error_code", errorCodeValue)
|
||||
}
|
||||
attrs = append(attrs, logging.TraceAttrsFromContext(c.Request.Context())...)
|
||||
|
||||
metricAttrs := []attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", c.Request.Method),
|
||||
attribute.String("edge_outcome", string(outcome)),
|
||||
}
|
||||
if errorCodeValue != "" {
|
||||
metricAttrs = append(metricAttrs, attribute.String("error_code", errorCodeValue))
|
||||
}
|
||||
metrics.RecordInternalHTTPRequest(c.Request.Context(), metricAttrs, duration)
|
||||
|
||||
switch outcome {
|
||||
case edgeOutcomeSuccess:
|
||||
logger.InfoContext(c.Request.Context(), "internal request completed", attrs...)
|
||||
case edgeOutcomeFailed:
|
||||
logger.ErrorContext(c.Request.Context(), "internal request failed", attrs...)
|
||||
default:
|
||||
logger.WarnContext(c.Request.Context(), "internal request rejected", attrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type edgeOutcome string
|
||||
|
||||
const (
|
||||
edgeOutcomeSuccess edgeOutcome = "success"
|
||||
edgeOutcomeRejected edgeOutcome = "rejected"
|
||||
edgeOutcomeFailed edgeOutcome = "failed"
|
||||
)
|
||||
|
||||
func outcomeFromStatusCode(statusCode int) edgeOutcome {
|
||||
switch {
|
||||
case statusCode >= 500:
|
||||
return edgeOutcomeFailed
|
||||
case statusCode >= 400:
|
||||
return edgeOutcomeRejected
|
||||
default:
|
||||
return edgeOutcomeSuccess
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const internalErrorCodeContextKey = "internal_error_code"
|
||||
|
||||
type malformedJSONRequestError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (err *malformedJSONRequestError) Error() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.message
|
||||
}
|
||||
|
||||
func decodeJSONRequest(request *http.Request, target any) error {
|
||||
if request == nil || request.Body == nil {
|
||||
return &malformedJSONRequestError{message: "request body must not be empty"}
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return describeJSONDecodeError(err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
|
||||
}
|
||||
|
||||
func describeJSONDecodeError(err error) error {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
return &malformedJSONRequestError{message: "request body must not be empty"}
|
||||
case errors.As(err, &syntaxErr):
|
||||
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
|
||||
case errors.As(err, &typeErr):
|
||||
if strings.TrimSpace(typeErr.Field) != "" {
|
||||
return &malformedJSONRequestError{
|
||||
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
|
||||
}
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body contains an invalid JSON value"}
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
return &malformedJSONRequestError{
|
||||
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
|
||||
}
|
||||
default:
|
||||
return &malformedJSONRequestError{message: "request body contains invalid JSON"}
|
||||
}
|
||||
}
|
||||
|
||||
func abortWithProjection(c *gin.Context, projection shared.InternalErrorProjection) {
|
||||
c.Set(internalErrorCodeContextKey, projection.Code)
|
||||
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
|
||||
Error: errorBody{
|
||||
Code: projection.Code,
|
||||
Message: projection.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
usertelemetry "galaxy/user/internal/telemetry"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestInternalHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger, buffer := newObservedLogger()
|
||||
telemetryRuntime, reader, recorder := newObservedInternalTelemetryRuntime(t)
|
||||
handler := mustNewHandler(t, Dependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
|
||||
return authdirectory.ExistsByUserIDResult{Exists: true}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
recorderHTTP := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/user-123/exists", nil)
|
||||
request.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
|
||||
|
||||
handler.ServeHTTP(recorderHTTP, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorderHTTP.Code)
|
||||
require.NotEmpty(t, recorder.Ended())
|
||||
assert.Contains(t, buffer.String(), "otel_trace_id")
|
||||
assert.Contains(t, buffer.String(), "otel_span_id")
|
||||
|
||||
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
|
||||
"route": "/api/v1/internal/users/:user_id/exists",
|
||||
"method": http.MethodGet,
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func newObservedInternalTelemetryRuntime(t *testing.T) (*usertelemetry.Runtime, *sdkmetric.ManualReader, *tracetest.SpanRecorder) {
|
||||
t.Helper()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
runtime, err := usertelemetry.NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
return runtime, reader, recorder
|
||||
}
|
||||
|
||||
func newObservedLogger() (*slog.Logger, *bytes.Buffer) {
|
||||
buffer := &bytes.Buffer{}
|
||||
return slog.New(slog.NewJSONHandler(buffer, &slog.HandlerOptions{Level: slog.LevelDebug})), buffer
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Package internalhttp exposes the trusted internal HTTP API used by auth,
|
||||
// gateway self-service, and internal administrative workflows.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/geosync"
|
||||
"galaxy/user/internal/service/lobbyeligibility"
|
||||
"galaxy/user/internal/service/policysvc"
|
||||
"galaxy/user/internal/service/selfservice"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
var configureGinModeOnce sync.Once
|
||||
|
||||
// ResolveByEmailUseCase describes the auth-facing resolve-by-email service
|
||||
// consumed by the HTTP transport layer.
|
||||
type ResolveByEmailUseCase interface {
|
||||
// Execute resolves one e-mail subject without creating any account.
|
||||
Execute(ctx context.Context, input authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error)
|
||||
}
|
||||
|
||||
// EnsureByEmailUseCase describes the auth-facing ensure-by-email service
|
||||
// consumed by the HTTP transport layer.
|
||||
type EnsureByEmailUseCase interface {
|
||||
// Execute returns an existing user, creates a new one, or reports a blocked
|
||||
// outcome for one e-mail subject.
|
||||
Execute(ctx context.Context, input authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error)
|
||||
}
|
||||
|
||||
// ExistsByUserIDUseCase describes the auth-facing exists-by-user-id service
|
||||
// consumed by the HTTP transport layer.
|
||||
type ExistsByUserIDUseCase interface {
|
||||
// Execute reports whether one stable user identifier exists.
|
||||
Execute(ctx context.Context, input authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error)
|
||||
}
|
||||
|
||||
// BlockByUserIDUseCase describes the auth-facing block-by-user-id service
|
||||
// consumed by the HTTP transport layer.
|
||||
type BlockByUserIDUseCase interface {
|
||||
// Execute blocks one account addressed by stable user identifier.
|
||||
Execute(ctx context.Context, input authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error)
|
||||
}
|
||||
|
||||
// BlockByEmailUseCase describes the auth-facing block-by-email service
|
||||
// consumed by the HTTP transport layer.
|
||||
type BlockByEmailUseCase interface {
|
||||
// Execute blocks one exact normalized e-mail subject.
|
||||
Execute(ctx context.Context, input authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error)
|
||||
}
|
||||
|
||||
// GetMyAccountUseCase describes the self-service account-read use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type GetMyAccountUseCase interface {
|
||||
// Execute returns the authenticated account aggregate for one user.
|
||||
Execute(ctx context.Context, input selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error)
|
||||
}
|
||||
|
||||
// UpdateMyProfileUseCase describes the self-service profile-mutation use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type UpdateMyProfileUseCase interface {
|
||||
// Execute updates the allowed self-service profile fields for one user.
|
||||
Execute(ctx context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error)
|
||||
}
|
||||
|
||||
// UpdateMySettingsUseCase describes the self-service settings-mutation use
|
||||
// case consumed by the HTTP transport layer.
|
||||
type UpdateMySettingsUseCase interface {
|
||||
// Execute updates the allowed self-service settings fields for one user.
|
||||
Execute(ctx context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error)
|
||||
}
|
||||
|
||||
// GetUserByIDUseCase describes the trusted admin exact-read by stable user id
|
||||
// consumed by the HTTP transport layer.
|
||||
type GetUserByIDUseCase interface {
|
||||
// Execute returns the full current account aggregate for one user id.
|
||||
Execute(ctx context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error)
|
||||
}
|
||||
|
||||
// GetUserByEmailUseCase describes the trusted admin exact-read by normalized
|
||||
// e-mail consumed by the HTTP transport layer.
|
||||
type GetUserByEmailUseCase interface {
|
||||
// Execute returns the full current account aggregate for one normalized
|
||||
// e-mail address.
|
||||
Execute(ctx context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error)
|
||||
}
|
||||
|
||||
// GetUserByRaceNameUseCase describes the trusted admin exact-read by exact
|
||||
// stored race name consumed by the HTTP transport layer.
|
||||
type GetUserByRaceNameUseCase interface {
|
||||
// Execute returns the full current account aggregate for one exact race
|
||||
// name.
|
||||
Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
|
||||
}
|
||||
|
||||
// ListUsersUseCase describes the trusted admin paginated listing use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type ListUsersUseCase interface {
|
||||
// Execute returns one deterministic filtered page of full account
|
||||
// aggregates.
|
||||
Execute(ctx context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error)
|
||||
}
|
||||
|
||||
// GetUserEligibilityUseCase describes the trusted lobby-facing eligibility
|
||||
// snapshot use case consumed by the HTTP transport layer.
|
||||
type GetUserEligibilityUseCase interface {
|
||||
// Execute returns one read-optimized lobby eligibility snapshot for one
|
||||
// user.
|
||||
Execute(ctx context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error)
|
||||
}
|
||||
|
||||
// SyncDeclaredCountryUseCase describes the trusted geo-facing declared-country
|
||||
// sync use case consumed by the HTTP transport layer.
|
||||
type SyncDeclaredCountryUseCase interface {
|
||||
// Execute synchronizes the current effective declared country for one user.
|
||||
Execute(ctx context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error)
|
||||
}
|
||||
|
||||
// GrantEntitlementUseCase describes the trusted entitlement-grant use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type GrantEntitlementUseCase interface {
|
||||
// Execute grants a new current paid entitlement for one user.
|
||||
Execute(ctx context.Context, input entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error)
|
||||
}
|
||||
|
||||
// ExtendEntitlementUseCase describes the trusted entitlement-extend use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type ExtendEntitlementUseCase interface {
|
||||
// Execute extends the current finite paid entitlement for one user.
|
||||
Execute(ctx context.Context, input entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error)
|
||||
}
|
||||
|
||||
// RevokeEntitlementUseCase describes the trusted entitlement-revoke use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type RevokeEntitlementUseCase interface {
|
||||
// Execute revokes the current paid entitlement for one user.
|
||||
Execute(ctx context.Context, input entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error)
|
||||
}
|
||||
|
||||
// ApplySanctionUseCase describes the trusted sanction-apply use case consumed
|
||||
// by the HTTP transport layer.
|
||||
type ApplySanctionUseCase interface {
|
||||
// Execute applies one new active sanction record.
|
||||
Execute(ctx context.Context, input policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error)
|
||||
}
|
||||
|
||||
// RemoveSanctionUseCase describes the trusted sanction-remove use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type RemoveSanctionUseCase interface {
|
||||
// Execute removes one current active sanction record by code.
|
||||
Execute(ctx context.Context, input policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error)
|
||||
}
|
||||
|
||||
// SetLimitUseCase describes the trusted limit-set use case consumed by the
|
||||
// HTTP transport layer.
|
||||
type SetLimitUseCase interface {
|
||||
// Execute creates or replaces one current active limit record.
|
||||
Execute(ctx context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error)
|
||||
}
|
||||
|
||||
// RemoveLimitUseCase describes the trusted limit-remove use case consumed by
|
||||
// the HTTP transport layer.
|
||||
type RemoveLimitUseCase interface {
|
||||
// Execute removes one current active limit record by code.
|
||||
Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
|
||||
}
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by the user
|
||||
// service.
|
||||
type Config struct {
|
||||
// Addr stores the TCP listen address.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading request
|
||||
// headers before rejecting the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// RequestTimeout bounds one application-layer request execution.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return errors.New("internal HTTP request timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the trusted internal HTTP
|
||||
// transport layer.
|
||||
type Dependencies struct {
|
||||
// ResolveByEmail executes the auth-facing resolve-by-email use case.
|
||||
ResolveByEmail ResolveByEmailUseCase
|
||||
|
||||
// EnsureByEmail executes the auth-facing ensure-by-email use case.
|
||||
EnsureByEmail EnsureByEmailUseCase
|
||||
|
||||
// ExistsByUserID executes the auth-facing exists-by-user-id use case.
|
||||
ExistsByUserID ExistsByUserIDUseCase
|
||||
|
||||
// BlockByUserID executes the auth-facing block-by-user-id use case.
|
||||
BlockByUserID BlockByUserIDUseCase
|
||||
|
||||
// BlockByEmail executes the auth-facing block-by-email use case.
|
||||
BlockByEmail BlockByEmailUseCase
|
||||
|
||||
// GetMyAccount executes the self-service authenticated account-read use
|
||||
// case.
|
||||
GetMyAccount GetMyAccountUseCase
|
||||
|
||||
// UpdateMyProfile executes the self-service profile-mutation use case.
|
||||
UpdateMyProfile UpdateMyProfileUseCase
|
||||
|
||||
// UpdateMySettings executes the self-service settings-mutation use case.
|
||||
UpdateMySettings UpdateMySettingsUseCase
|
||||
|
||||
// GetUserByID executes the trusted admin exact-read by stable user id.
|
||||
GetUserByID GetUserByIDUseCase
|
||||
|
||||
// GetUserByEmail executes the trusted admin exact-read by normalized
|
||||
// e-mail.
|
||||
GetUserByEmail GetUserByEmailUseCase
|
||||
|
||||
// GetUserByRaceName executes the trusted admin exact-read by exact stored
|
||||
// race name.
|
||||
GetUserByRaceName GetUserByRaceNameUseCase
|
||||
|
||||
// ListUsers executes the trusted admin paginated filtered listing use case.
|
||||
ListUsers ListUsersUseCase
|
||||
|
||||
// GetUserEligibility executes the trusted lobby-facing eligibility snapshot
|
||||
// read.
|
||||
GetUserEligibility GetUserEligibilityUseCase
|
||||
|
||||
// SyncDeclaredCountry executes the trusted geo-facing declared-country sync
|
||||
// command.
|
||||
SyncDeclaredCountry SyncDeclaredCountryUseCase
|
||||
|
||||
// GrantEntitlement executes the trusted entitlement-grant use case.
|
||||
GrantEntitlement GrantEntitlementUseCase
|
||||
|
||||
// ExtendEntitlement executes the trusted entitlement-extend use case.
|
||||
ExtendEntitlement ExtendEntitlementUseCase
|
||||
|
||||
// RevokeEntitlement executes the trusted entitlement-revoke use case.
|
||||
RevokeEntitlement RevokeEntitlementUseCase
|
||||
|
||||
// ApplySanction executes the trusted sanction-apply use case.
|
||||
ApplySanction ApplySanctionUseCase
|
||||
|
||||
// RemoveSanction executes the trusted sanction-remove use case.
|
||||
RemoveSanction RemoveSanctionUseCase
|
||||
|
||||
// SetLimit executes the trusted limit-set use case.
|
||||
SetLimit SetLimitUseCase
|
||||
|
||||
// RemoveLimit executes the trusted limit-remove use case.
|
||||
RemoveLimit RemoveLimitUseCase
|
||||
|
||||
// Logger writes structured transport logs. When nil, the default logger is
|
||||
// used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records OpenTelemetry spans and low-cardinality HTTP metrics.
|
||||
Telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by the user service.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
handler, err := newHandlerWithConfig(cfg, deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the trusted internal HTTP
|
||||
// surface until ctx is cancelled or Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(shutdownDone)
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.RequestTimeout)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
<-shutdownDone
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// Package app wires the runnable user-service process.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"galaxy/user/internal/adapters/local"
|
||||
"galaxy/user/internal/adapters/redis/domainevents"
|
||||
"galaxy/user/internal/adapters/redis/userstore"
|
||||
"galaxy/user/internal/adminapi"
|
||||
"galaxy/user/internal/api/internalhttp"
|
||||
"galaxy/user/internal/config"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/geosync"
|
||||
"galaxy/user/internal/service/lobbyeligibility"
|
||||
"galaxy/user/internal/service/policysvc"
|
||||
"galaxy/user/internal/service/selfservice"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
type pinger interface {
|
||||
Ping(context.Context) error
|
||||
}
|
||||
|
||||
type closer interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Runtime owns the runnable user-service process plus the cleanup functions
|
||||
// that release runtime resources after shutdown.
|
||||
type Runtime struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
|
||||
// Server owns the internal HTTP listener exposed by the user service.
|
||||
Server *internalhttp.Server
|
||||
|
||||
// AdminServer owns the optional private admin HTTP listener.
|
||||
AdminServer *adminapi.Server
|
||||
|
||||
// Telemetry owns the process-wide OpenTelemetry providers and Prometheus
|
||||
// handler.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
cleanupFns []func() error
|
||||
}
|
||||
|
||||
// NewRuntime constructs the runnable user-service process from cfg.
|
||||
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("new user-service runtime: nil context")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new user-service runtime: %w", err)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
runtime := &Runtime{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
cleanupOnError := func(err error) (*Runtime, error) {
|
||||
return nil, fmt.Errorf("%w; cleanup: %w", err, runtime.Close())
|
||||
}
|
||||
|
||||
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
|
||||
ServiceName: cfg.Telemetry.ServiceName,
|
||||
TracesExporter: cfg.Telemetry.TracesExporter,
|
||||
MetricsExporter: cfg.Telemetry.MetricsExporter,
|
||||
TracesProtocol: cfg.Telemetry.TracesProtocol,
|
||||
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
|
||||
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
|
||||
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
|
||||
}, logger.With("component", "telemetry"))
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: telemetry runtime: %w", err))
|
||||
}
|
||||
runtime.Telemetry = telemetryRuntime
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
return telemetryRuntime.Shutdown(shutdownCtx)
|
||||
})
|
||||
|
||||
store, err := userstore.New(userstore.Config{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
TLSEnabled: cfg.Redis.TLSEnabled,
|
||||
KeyspacePrefix: cfg.Redis.KeyspacePrefix,
|
||||
OperationTimeout: cfg.Redis.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: redis user store: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, store.Close)
|
||||
|
||||
if err := pingDependency(ctx, "redis user store", store); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
|
||||
}
|
||||
|
||||
domainEventPublisher, err := domainevents.New(domainevents.Config{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
TLSEnabled: cfg.Redis.TLSEnabled,
|
||||
Stream: cfg.Redis.DomainEventsStream,
|
||||
StreamMaxLen: cfg.Redis.DomainEventsStreamMaxLen,
|
||||
OperationTimeout: cfg.Redis.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: redis domain-event publisher: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, domainEventPublisher.Close)
|
||||
|
||||
if err := pingDependency(ctx, "redis domain-event publisher", domainEventPublisher); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
|
||||
}
|
||||
|
||||
clock := local.Clock{}
|
||||
idGenerator := local.IDGenerator{}
|
||||
raceNamePolicy, err := local.NewRaceNamePolicy()
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: race-name policy: %w", err))
|
||||
}
|
||||
|
||||
componentLogger := func(component string) *slog.Logger {
|
||||
return logger.With("component", component)
|
||||
}
|
||||
|
||||
resolver, err := authdirectory.NewResolverWithObservability(store, componentLogger("authdirectory"), telemetryRuntime)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: resolver: %w", err))
|
||||
}
|
||||
ensurer, err := authdirectory.NewEnsurerWithObservability(
|
||||
store,
|
||||
clock,
|
||||
idGenerator,
|
||||
raceNamePolicy,
|
||||
componentLogger("authdirectory"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
domainEventPublisher,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: ensurer: %w", err))
|
||||
}
|
||||
existenceChecker, err := authdirectory.NewExistenceChecker(store)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: existence checker: %w", err))
|
||||
}
|
||||
blockByUserID, err := authdirectory.NewBlockByUserIDService(store, clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-user-id service: %w", err))
|
||||
}
|
||||
blockByEmail, err := authdirectory.NewBlockByEmailService(store, clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-email service: %w", err))
|
||||
}
|
||||
entitlementReader, err := entitlementsvc.NewReaderWithObservability(
|
||||
store.EntitlementSnapshots(),
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: entitlement reader: %w", err))
|
||||
}
|
||||
grantEntitlement, err := entitlementsvc.NewGrantServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.EntitlementHistory(),
|
||||
entitlementReader,
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: grant entitlement service: %w", err))
|
||||
}
|
||||
extendEntitlement, err := entitlementsvc.NewExtendServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.EntitlementHistory(),
|
||||
entitlementReader,
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: extend entitlement service: %w", err))
|
||||
}
|
||||
revokeEntitlement, err := entitlementsvc.NewRevokeServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.EntitlementHistory(),
|
||||
entitlementReader,
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: revoke entitlement service: %w", err))
|
||||
}
|
||||
accountGetter, err := selfservice.NewAccountGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: account getter: %w", err))
|
||||
}
|
||||
profileUpdater, err := selfservice.NewProfileUpdaterWithObservability(
|
||||
store.Accounts(),
|
||||
entitlementReader,
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
clock,
|
||||
raceNamePolicy,
|
||||
componentLogger("selfservice"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: profile updater: %w", err))
|
||||
}
|
||||
settingsUpdater, err := selfservice.NewSettingsUpdaterWithObservability(
|
||||
store.Accounts(),
|
||||
entitlementReader,
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
clock,
|
||||
componentLogger("selfservice"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: settings updater: %w", err))
|
||||
}
|
||||
getUserByID, err := adminusers.NewByIDGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-id: %w", err))
|
||||
}
|
||||
getUserByEmail, err := adminusers.NewByEmailGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-email: %w", err))
|
||||
}
|
||||
getUserByRaceName, err := adminusers.NewByRaceNameGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-race-name: %w", err))
|
||||
}
|
||||
listUsers, err := adminusers.NewLister(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock, store)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin list-users: %w", err))
|
||||
}
|
||||
userEligibility, err := lobbyeligibility.NewSnapshotReader(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: lobby eligibility snapshot reader: %w", err))
|
||||
}
|
||||
syncDeclaredCountry, err := geosync.NewSyncServiceWithObservability(
|
||||
store.Accounts(),
|
||||
clock,
|
||||
domainEventPublisher,
|
||||
componentLogger("geosync"),
|
||||
telemetryRuntime,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: geo declared-country sync service: %w", err))
|
||||
}
|
||||
applySanction, err := policysvc.NewApplySanctionServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: apply sanction service: %w", err))
|
||||
}
|
||||
removeSanction, err := policysvc.NewRemoveSanctionServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: remove sanction service: %w", err))
|
||||
}
|
||||
setLimit, err := policysvc.NewSetLimitServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: set limit service: %w", err))
|
||||
}
|
||||
removeLimit, err := policysvc.NewRemoveLimitServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: remove limit service: %w", err))
|
||||
}
|
||||
|
||||
server, err := internalhttp.NewServer(internalhttp.Config{
|
||||
Addr: cfg.InternalHTTP.Addr,
|
||||
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
|
||||
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
|
||||
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
|
||||
RequestTimeout: cfg.InternalHTTP.RequestTimeout,
|
||||
}, internalhttp.Dependencies{
|
||||
ResolveByEmail: resolver,
|
||||
EnsureByEmail: ensurer,
|
||||
ExistsByUserID: existenceChecker,
|
||||
BlockByUserID: blockByUserID,
|
||||
BlockByEmail: blockByEmail,
|
||||
GetMyAccount: accountGetter,
|
||||
UpdateMyProfile: profileUpdater,
|
||||
UpdateMySettings: settingsUpdater,
|
||||
GetUserByID: getUserByID,
|
||||
GetUserByEmail: getUserByEmail,
|
||||
GetUserByRaceName: getUserByRaceName,
|
||||
ListUsers: listUsers,
|
||||
GetUserEligibility: userEligibility,
|
||||
SyncDeclaredCountry: syncDeclaredCountry,
|
||||
GrantEntitlement: grantEntitlement,
|
||||
ExtendEntitlement: extendEntitlement,
|
||||
RevokeEntitlement: revokeEntitlement,
|
||||
ApplySanction: applySanction,
|
||||
RemoveSanction: removeSanction,
|
||||
SetLimit: setLimit,
|
||||
RemoveLimit: removeLimit,
|
||||
Logger: logger.With("component", "internal_http"),
|
||||
Telemetry: telemetryRuntime,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: internal HTTP server: %w", err))
|
||||
}
|
||||
|
||||
adminServer := adminapi.NewServer(cfg.AdminHTTP, telemetryRuntime.Handler(), logger)
|
||||
|
||||
runtime.Server = server
|
||||
runtime.AdminServer = adminServer
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// Run serves the internal and admin HTTP listeners until ctx is canceled or a
|
||||
// listener fails.
|
||||
func (runtime *Runtime) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run user-service runtime: nil context")
|
||||
}
|
||||
if runtime == nil {
|
||||
return errors.New("run user-service runtime: nil runtime")
|
||||
}
|
||||
if runtime.Server == nil {
|
||||
return errors.New("run user-service runtime: nil internal HTTP server")
|
||||
}
|
||||
if runtime.AdminServer == nil {
|
||||
return errors.New("run user-service runtime: nil admin HTTP server")
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
shutdownMu sync.Mutex
|
||||
shutdownDone bool
|
||||
shutdownErr error
|
||||
)
|
||||
shutdownServers := func() {
|
||||
shutdownMu.Lock()
|
||||
defer shutdownMu.Unlock()
|
||||
if shutdownDone {
|
||||
return
|
||||
}
|
||||
shutdownDone = true
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), runtime.cfg.ShutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
shutdownErr = errors.Join(
|
||||
runtime.Server.Shutdown(shutdownCtx),
|
||||
runtime.AdminServer.Shutdown(shutdownCtx),
|
||||
)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
runServer := func(name string, serve func(context.Context) error) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := serve(runCtx); err != nil {
|
||||
select {
|
||||
case errCh <- fmt.Errorf("%s: %w", name, err):
|
||||
default:
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
runServer("internal HTTP server", runtime.Server.Run)
|
||||
runServer("admin HTTP server", runtime.AdminServer.Run)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
<-runCtx.Done()
|
||||
shutdownServers()
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
var runErr error
|
||||
select {
|
||||
case runErr = <-errCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
case <-done:
|
||||
}
|
||||
|
||||
<-done
|
||||
return errors.Join(runErr, shutdownErr)
|
||||
}
|
||||
|
||||
// Close releases every runtime dependency in reverse construction order.
|
||||
func (runtime *Runtime) Close() error {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []string
|
||||
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
|
||||
if err := runtime.cleanupFns[index](); err != nil {
|
||||
messages = append(messages, err.Error())
|
||||
}
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New(strings.Join(messages, "; "))
|
||||
}
|
||||
|
||||
func pingDependency(ctx context.Context, name string, dependency pinger) error {
|
||||
if err := dependency.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("ping %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ closer = (*userstore.Store)(nil)
|
||||
@@ -0,0 +1,551 @@
|
||||
// Package config loads the user-service process configuration from environment
|
||||
// variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
shutdownTimeoutEnvVar = "USERSERVICE_SHUTDOWN_TIMEOUT"
|
||||
logLevelEnvVar = "USERSERVICE_LOG_LEVEL"
|
||||
|
||||
internalHTTPAddrEnvVar = "USERSERVICE_INTERNAL_HTTP_ADDR"
|
||||
internalHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
|
||||
internalHTTPReadTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT"
|
||||
internalHTTPIdleTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT"
|
||||
internalHTTPRequestTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT"
|
||||
|
||||
adminHTTPAddrEnvVar = "USERSERVICE_ADMIN_HTTP_ADDR"
|
||||
adminHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT"
|
||||
adminHTTPReadTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_TIMEOUT"
|
||||
adminHTTPIdleTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT"
|
||||
|
||||
redisAddrEnvVar = "USERSERVICE_REDIS_ADDR"
|
||||
redisUsernameEnvVar = "USERSERVICE_REDIS_USERNAME"
|
||||
redisPasswordEnvVar = "USERSERVICE_REDIS_PASSWORD"
|
||||
redisDBEnvVar = "USERSERVICE_REDIS_DB"
|
||||
redisTLSEnabledEnvVar = "USERSERVICE_REDIS_TLS_ENABLED"
|
||||
redisOperationTimeoutEnvVar = "USERSERVICE_REDIS_OPERATION_TIMEOUT"
|
||||
redisKeyspacePrefixEnvVar = "USERSERVICE_REDIS_KEYSPACE_PREFIX"
|
||||
redisDomainEventsStreamEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM"
|
||||
redisDomainEventsStreamMaxLenEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN"
|
||||
|
||||
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
|
||||
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
|
||||
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
|
||||
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
|
||||
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
|
||||
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
|
||||
otelStdoutTracesEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_TRACES_ENABLED"
|
||||
otelStdoutMetricsEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_METRICS_ENABLED"
|
||||
|
||||
defaultShutdownTimeout = 5 * time.Second
|
||||
defaultLogLevel = "info"
|
||||
defaultInternalHTTPAddr = ":8091"
|
||||
defaultAdminHTTPAddr = ""
|
||||
defaultReadHeaderTimeout = 2 * time.Second
|
||||
defaultReadTimeout = 10 * time.Second
|
||||
defaultIdleTimeout = time.Minute
|
||||
defaultRequestTimeout = 3 * time.Second
|
||||
defaultRedisDB = 0
|
||||
defaultRedisOperationTimeout = 250 * time.Millisecond
|
||||
defaultRedisKeyspacePrefix = "user:"
|
||||
defaultDomainEventsStream = "user:domain_events"
|
||||
defaultDomainEventsStreamMaxLen = 1024
|
||||
defaultOTelServiceName = "galaxy-user"
|
||||
otelExporterNone = "none"
|
||||
otelExporterOTLP = "otlp"
|
||||
otelProtocolHTTPProtobuf = "http/protobuf"
|
||||
otelProtocolGRPC = "grpc"
|
||||
)
|
||||
|
||||
// Config stores the full user-service process configuration.
|
||||
type Config struct {
|
||||
// ShutdownTimeout bounds graceful shutdown of the long-lived listeners and
|
||||
// runtime resources.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// Logging configures the process-wide logger.
|
||||
Logging LoggingConfig
|
||||
|
||||
// InternalHTTP configures the trusted internal HTTP listener.
|
||||
InternalHTTP InternalHTTPConfig
|
||||
|
||||
// AdminHTTP configures the optional private admin HTTP listener.
|
||||
AdminHTTP AdminHTTPConfig
|
||||
|
||||
// Redis configures the Redis-backed user store and domain-event publisher.
|
||||
Redis RedisConfig
|
||||
|
||||
// Telemetry configures the process-wide OpenTelemetry runtime.
|
||||
Telemetry TelemetryConfig
|
||||
}
|
||||
|
||||
// LoggingConfig configures the process-wide logger.
|
||||
type LoggingConfig struct {
|
||||
// Level stores the process log level.
|
||||
Level string
|
||||
}
|
||||
|
||||
// InternalHTTPConfig configures the internal HTTP listener.
|
||||
type InternalHTTPConfig struct {
|
||||
// Addr stores the TCP listen address.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds request-header reading.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// RequestTimeout bounds one application-layer request execution.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg InternalHTTPConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP idle timeout must be positive")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP request timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AdminHTTPConfig describes the private operational HTTP listener used for
|
||||
// Prometheus metrics exposure. The listener remains disabled when Addr is
|
||||
// empty.
|
||||
type AdminHTTPConfig struct {
|
||||
// Addr stores the TCP listen address used by the admin HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds request-header reading.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable optional admin HTTP listener
|
||||
// configuration.
|
||||
func (cfg AdminHTTPConfig) Validate() error {
|
||||
if strings.TrimSpace(cfg.Addr) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return fmt.Errorf("admin HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return fmt.Errorf("admin HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return fmt.Errorf("admin HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RedisConfig configures the Redis-backed store and domain-event publisher.
|
||||
type RedisConfig struct {
|
||||
// Addr stores the Redis network address.
|
||||
Addr string
|
||||
|
||||
// Username stores the optional Redis ACL username.
|
||||
Username string
|
||||
|
||||
// Password stores the optional Redis ACL password.
|
||||
Password string
|
||||
|
||||
// DB stores the Redis logical database index.
|
||||
DB int
|
||||
|
||||
// TLSEnabled reports whether TLS must be used for Redis connections.
|
||||
TLSEnabled bool
|
||||
|
||||
// OperationTimeout bounds one Redis round trip.
|
||||
OperationTimeout time.Duration
|
||||
|
||||
// KeyspacePrefix stores the root prefix of the service-owned Redis keyspace.
|
||||
KeyspacePrefix string
|
||||
|
||||
// DomainEventsStream stores the Redis Stream key used for auxiliary
|
||||
// post-commit domain events.
|
||||
DomainEventsStream string
|
||||
|
||||
// DomainEventsStreamMaxLen bounds the domain-events Redis Stream with
|
||||
// approximate trimming.
|
||||
DomainEventsStreamMaxLen int64
|
||||
}
|
||||
|
||||
// TLSConfig returns the conservative TLS configuration used by Redis adapters
|
||||
// when TLSEnabled is true.
|
||||
func (cfg RedisConfig) TLSConfig() *tls.Config {
|
||||
if !cfg.TLSEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Redis configuration.
|
||||
func (cfg RedisConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("redis addr must not be empty")
|
||||
case cfg.DB < 0:
|
||||
return fmt.Errorf("redis db must not be negative")
|
||||
case cfg.OperationTimeout <= 0:
|
||||
return fmt.Errorf("redis operation timeout must be positive")
|
||||
case strings.TrimSpace(cfg.KeyspacePrefix) == "":
|
||||
return fmt.Errorf("redis keyspace prefix must not be empty")
|
||||
case strings.TrimSpace(cfg.DomainEventsStream) == "":
|
||||
return fmt.Errorf("redis domain events stream must not be empty")
|
||||
case cfg.DomainEventsStreamMaxLen <= 0:
|
||||
return fmt.Errorf("redis domain events stream max len must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TelemetryConfig configures the user-service OpenTelemetry runtime.
|
||||
type TelemetryConfig struct {
|
||||
// ServiceName overrides the default OpenTelemetry service name.
|
||||
ServiceName string
|
||||
|
||||
// TracesExporter selects the external traces exporter. Supported values are
|
||||
// `none` and `otlp`.
|
||||
TracesExporter string
|
||||
|
||||
// MetricsExporter selects the external metrics exporter. Supported values
|
||||
// are `none` and `otlp`.
|
||||
MetricsExporter string
|
||||
|
||||
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
|
||||
// `otlp`.
|
||||
TracesProtocol string
|
||||
|
||||
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
|
||||
// `otlp`.
|
||||
MetricsProtocol string
|
||||
|
||||
// StdoutTracesEnabled enables the additional stdout trace exporter used for
|
||||
// local development and debugging.
|
||||
StdoutTracesEnabled bool
|
||||
|
||||
// StdoutMetricsEnabled enables the additional stdout metric exporter used
|
||||
// for local development and debugging.
|
||||
StdoutMetricsEnabled bool
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a supported OpenTelemetry exporter
|
||||
// configuration.
|
||||
func (cfg TelemetryConfig) Validate() error {
|
||||
switch cfg.TracesExporter {
|
||||
case otelExporterNone, otelExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("%s %q is unsupported", otelTracesExporterEnvVar, cfg.TracesExporter)
|
||||
}
|
||||
|
||||
switch cfg.MetricsExporter {
|
||||
case otelExporterNone, otelExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("%s %q is unsupported", otelMetricsExporterEnvVar, cfg.MetricsExporter)
|
||||
}
|
||||
|
||||
if cfg.TracesProtocol != "" && cfg.TracesProtocol != otelProtocolHTTPProtobuf && cfg.TracesProtocol != otelProtocolGRPC {
|
||||
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPTracesProtocolEnvVar, cfg.TracesProtocol)
|
||||
}
|
||||
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != otelProtocolHTTPProtobuf && cfg.MetricsProtocol != otelProtocolGRPC {
|
||||
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPMetricsProtocolEnvVar, cfg.MetricsProtocol)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultAdminHTTPConfig returns the default settings for the optional private
|
||||
// admin HTTP listener.
|
||||
func DefaultAdminHTTPConfig() AdminHTTPConfig {
|
||||
return AdminHTTPConfig{
|
||||
Addr: defaultAdminHTTPAddr,
|
||||
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default process configuration with all optional
|
||||
// values filled.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
ShutdownTimeout: defaultShutdownTimeout,
|
||||
Logging: LoggingConfig{
|
||||
Level: defaultLogLevel,
|
||||
},
|
||||
InternalHTTP: InternalHTTPConfig{
|
||||
Addr: defaultInternalHTTPAddr,
|
||||
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
RequestTimeout: defaultRequestTimeout,
|
||||
},
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
Redis: RedisConfig{
|
||||
DB: defaultRedisDB,
|
||||
OperationTimeout: defaultRedisOperationTimeout,
|
||||
KeyspacePrefix: defaultRedisKeyspacePrefix,
|
||||
DomainEventsStream: defaultDomainEventsStream,
|
||||
DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen,
|
||||
},
|
||||
Telemetry: TelemetryConfig{
|
||||
ServiceName: defaultOTelServiceName,
|
||||
TracesExporter: otelExporterNone,
|
||||
MetricsExporter: otelExporterNone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg is process-ready.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.ShutdownTimeout <= 0:
|
||||
return fmt.Errorf("shutdown timeout must be positive")
|
||||
}
|
||||
if err := cfg.InternalHTTP.Validate(); err != nil {
|
||||
return fmt.Errorf("internal HTTP config: %w", err)
|
||||
}
|
||||
if err := cfg.AdminHTTP.Validate(); err != nil {
|
||||
return fmt.Errorf("admin HTTP config: %w", err)
|
||||
}
|
||||
if err := cfg.Redis.Validate(); err != nil {
|
||||
return fmt.Errorf("redis config: %w", err)
|
||||
}
|
||||
if _, err := parseLogLevel(cfg.Logging.Level); err != nil {
|
||||
return fmt.Errorf("logging config: %w", err)
|
||||
}
|
||||
if err := cfg.Telemetry.Validate(); err != nil {
|
||||
return fmt.Errorf("telemetry config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFromEnv loads Config from the process environment.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
var err error
|
||||
cfg.ShutdownTimeout, err = loadDuration(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Logging.Level = loadString(logLevelEnvVar, cfg.Logging.Level)
|
||||
|
||||
cfg.InternalHTTP.Addr = loadString(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
|
||||
cfg.InternalHTTP.ReadHeaderTimeout, err = loadDuration(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.ReadTimeout, err = loadDuration(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.IdleTimeout, err = loadDuration(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.RequestTimeout, err = loadDuration(internalHTTPRequestTimeoutEnvVar, cfg.InternalHTTP.RequestTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.AdminHTTP.Addr = loadString(adminHTTPAddrEnvVar, cfg.AdminHTTP.Addr)
|
||||
cfg.AdminHTTP.ReadHeaderTimeout, err = loadDuration(adminHTTPReadHeaderTimeoutEnvVar, cfg.AdminHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AdminHTTP.ReadTimeout, err = loadDuration(adminHTTPReadTimeoutEnvVar, cfg.AdminHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AdminHTTP.IdleTimeout, err = loadDuration(adminHTTPIdleTimeoutEnvVar, cfg.AdminHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Redis.Addr = loadString(redisAddrEnvVar, cfg.Redis.Addr)
|
||||
cfg.Redis.Username = loadString(redisUsernameEnvVar, cfg.Redis.Username)
|
||||
cfg.Redis.Password = loadString(redisPasswordEnvVar, cfg.Redis.Password)
|
||||
cfg.Redis.DB, err = loadInt(redisDBEnvVar, cfg.Redis.DB)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.TLSEnabled, err = loadBool(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.OperationTimeout, err = loadDuration(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.KeyspacePrefix = loadString(redisKeyspacePrefixEnvVar, cfg.Redis.KeyspacePrefix)
|
||||
cfg.Redis.DomainEventsStream = loadString(redisDomainEventsStreamEnvVar, cfg.Redis.DomainEventsStream)
|
||||
cfg.Redis.DomainEventsStreamMaxLen, err = loadInt64(redisDomainEventsStreamMaxLenEnvVar, cfg.Redis.DomainEventsStreamMaxLen)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Telemetry.ServiceName = loadString(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(loadString(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadString(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
|
||||
cfg.Telemetry.TracesProtocol = loadOTLPProtocol(
|
||||
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.TracesExporter,
|
||||
)
|
||||
cfg.Telemetry.MetricsProtocol = loadOTLPProtocol(
|
||||
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.MetricsExporter,
|
||||
)
|
||||
cfg.Telemetry.StdoutTracesEnabled, err = loadBool(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Telemetry.StdoutMetricsEnabled, err = loadBool(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadString(envName string, defaultValue string) string {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func loadDuration(envName string, defaultValue time.Duration) (time.Duration, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
duration, err := time.ParseDuration(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse duration: %w", envName, err)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func loadInt(envName string, defaultValue int) (int, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
parsedValue, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse int: %w", envName, err)
|
||||
}
|
||||
|
||||
return parsedValue, nil
|
||||
}
|
||||
|
||||
func loadInt64(envName string, defaultValue int64) (int64, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
parsedValue, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse int64: %w", envName, err)
|
||||
}
|
||||
|
||||
return parsedValue, nil
|
||||
}
|
||||
|
||||
func loadBool(envName string, defaultValue bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
parsedValue, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: parse bool: %w", envName, err)
|
||||
}
|
||||
|
||||
return parsedValue, nil
|
||||
}
|
||||
|
||||
func parseLogLevel(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug", "info", "warn", "error":
|
||||
return value, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported log level %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExporterValue(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", otelExporterNone:
|
||||
return otelExporterNone
|
||||
default:
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func loadOTLPProtocol(primary string, fallback string, exporter string) string {
|
||||
protocol := strings.TrimSpace(primary)
|
||||
if protocol == "" {
|
||||
protocol = strings.TrimSpace(fallback)
|
||||
}
|
||||
if protocol == "" && exporter == otelExporterOTLP {
|
||||
return otelProtocolHTTPProtobuf
|
||||
}
|
||||
|
||||
return protocol
|
||||
}
|
||||
|
||||
// ListenAddress returns the resolved listen address used by tests and process
|
||||
// startup.
|
||||
func (cfg InternalHTTPConfig) ListenAddress() string {
|
||||
if strings.HasPrefix(cfg.Addr, ":") {
|
||||
return net.JoinHostPort("", strings.TrimPrefix(cfg.Addr, ":"))
|
||||
}
|
||||
|
||||
return cfg.Addr
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
defaults := DefaultConfig()
|
||||
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
|
||||
require.Equal(t, defaults.Logging.Level, cfg.Logging.Level)
|
||||
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
|
||||
require.Equal(t, defaults.AdminHTTP, cfg.AdminHTTP)
|
||||
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
|
||||
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
|
||||
require.Equal(t, defaults.Redis.DomainEventsStream, cfg.Redis.DomainEventsStream)
|
||||
require.Equal(t, defaults.Redis.DomainEventsStreamMaxLen, cfg.Redis.DomainEventsStreamMaxLen)
|
||||
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv(shutdownTimeoutEnvVar, "9s")
|
||||
t.Setenv(logLevelEnvVar, "debug")
|
||||
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18091")
|
||||
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
|
||||
t.Setenv(internalHTTPRequestTimeoutEnvVar, "750ms")
|
||||
t.Setenv(adminHTTPAddrEnvVar, "127.0.0.1:19091")
|
||||
t.Setenv(adminHTTPIdleTimeoutEnvVar, "90s")
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
|
||||
t.Setenv(redisUsernameEnvVar, "alice")
|
||||
t.Setenv(redisPasswordEnvVar, "secret")
|
||||
t.Setenv(redisDBEnvVar, "3")
|
||||
t.Setenv(redisTLSEnabledEnvVar, "true")
|
||||
t.Setenv(redisOperationTimeoutEnvVar, "900ms")
|
||||
t.Setenv(redisKeyspacePrefixEnvVar, "user:custom:")
|
||||
t.Setenv(redisDomainEventsStreamEnvVar, "user:test_events")
|
||||
t.Setenv(redisDomainEventsStreamMaxLenEnvVar, "2048")
|
||||
t.Setenv(otelServiceNameEnvVar, "galaxy-user-stage12")
|
||||
t.Setenv(otelTracesExporterEnvVar, "otlp")
|
||||
t.Setenv(otelMetricsExporterEnvVar, "otlp")
|
||||
t.Setenv(otelExporterOTLPTracesProtocolEnvVar, "grpc")
|
||||
t.Setenv(otelExporterOTLPMetricsProtocolEnvVar, "http/protobuf")
|
||||
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
|
||||
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 9*time.Second, cfg.ShutdownTimeout)
|
||||
require.Equal(t, "debug", cfg.Logging.Level)
|
||||
require.Equal(t, "127.0.0.1:18091", cfg.InternalHTTP.Addr)
|
||||
require.Equal(t, 3*time.Second, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
require.Equal(t, 750*time.Millisecond, cfg.InternalHTTP.RequestTimeout)
|
||||
require.Equal(t, "127.0.0.1:19091", cfg.AdminHTTP.Addr)
|
||||
require.Equal(t, 90*time.Second, cfg.AdminHTTP.IdleTimeout)
|
||||
require.Equal(t, "127.0.0.1:6380", cfg.Redis.Addr)
|
||||
require.Equal(t, "alice", cfg.Redis.Username)
|
||||
require.Equal(t, "secret", cfg.Redis.Password)
|
||||
require.Equal(t, 3, cfg.Redis.DB)
|
||||
require.True(t, cfg.Redis.TLSEnabled)
|
||||
require.Equal(t, 900*time.Millisecond, cfg.Redis.OperationTimeout)
|
||||
require.Equal(t, "user:custom:", cfg.Redis.KeyspacePrefix)
|
||||
require.Equal(t, "user:test_events", cfg.Redis.DomainEventsStream)
|
||||
require.Equal(t, int64(2048), cfg.Redis.DomainEventsStreamMaxLen)
|
||||
require.Equal(t, "galaxy-user-stage12", cfg.Telemetry.ServiceName)
|
||||
require.Equal(t, "otlp", cfg.Telemetry.TracesExporter)
|
||||
require.Equal(t, "otlp", cfg.Telemetry.MetricsExporter)
|
||||
require.Equal(t, "grpc", cfg.Telemetry.TracesProtocol)
|
||||
require.Equal(t, "http/protobuf", cfg.Telemetry.MetricsProtocol)
|
||||
require.True(t, cfg.Telemetry.StdoutTracesEnabled)
|
||||
require.True(t, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
envVal string
|
||||
}{
|
||||
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
|
||||
{name: "invalid bool", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
|
||||
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
|
||||
{name: "invalid int", envName: redisDBEnvVar, envVal: "db-three"},
|
||||
{name: "invalid stream max len", envName: redisDomainEventsStreamMaxLenEnvVar, envVal: "many"},
|
||||
{name: "invalid traces exporter", envName: otelTracesExporterEnvVar, envVal: "zipkin"},
|
||||
{name: "invalid metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package logging configures the user-service process logger and provides
|
||||
// context-aware helpers for attaching OpenTelemetry trace identifiers.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// New constructs the process-wide JSON logger from level.
|
||||
func New(level string) (*slog.Logger, error) {
|
||||
var slogLevel slog.Level
|
||||
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
|
||||
return nil, fmt.Errorf("build logger: %w", err)
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slogLevel,
|
||||
})), nil
|
||||
}
|
||||
|
||||
// TraceAttrsFromContext returns slog key-value pairs for the active
|
||||
// OpenTelemetry span when ctx carries a valid span context.
|
||||
func TraceAttrsFromContext(ctx context.Context) []any {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if !spanContext.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []any{
|
||||
"otel_trace_id", spanContext.TraceID().String(),
|
||||
"otel_span_id", spanContext.SpanID().String(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// CreateAccountInput stores the atomic account-create state that must commit
|
||||
// together.
|
||||
type CreateAccountInput struct {
|
||||
// Account stores the durable user-account state.
|
||||
Account account.UserAccount
|
||||
|
||||
// Reservation stores the canonical race-name reservation linked to Account.
|
||||
Reservation account.RaceNameReservation
|
||||
}
|
||||
|
||||
// Validate reports whether CreateAccountInput is structurally complete.
|
||||
func (input CreateAccountInput) Validate() error {
|
||||
if err := input.Account.Validate(); err != nil {
|
||||
return fmt.Errorf("create account input account: %w", err)
|
||||
}
|
||||
if err := input.Reservation.Validate(); err != nil {
|
||||
return fmt.Errorf("create account input reservation: %w", err)
|
||||
}
|
||||
if input.Account.UserID != input.Reservation.UserID {
|
||||
return fmt.Errorf("create account input reservation user id must match account user id")
|
||||
}
|
||||
if input.Account.RaceName != input.Reservation.RaceName {
|
||||
return fmt.Errorf("create account input reservation race name must match account race name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameRaceNameInput stores the atomic state required to replace one stored
|
||||
// race name and its canonical reservation.
|
||||
type RenameRaceNameInput struct {
|
||||
// UserID identifies the account that must be updated.
|
||||
UserID common.UserID
|
||||
|
||||
// CurrentCanonicalKey stores the currently owned canonical reservation key.
|
||||
CurrentCanonicalKey account.RaceNameCanonicalKey
|
||||
|
||||
// NewRaceName stores the replacement exact stored race name.
|
||||
NewRaceName common.RaceName
|
||||
|
||||
// NewReservation stores the replacement canonical reservation.
|
||||
NewReservation account.RaceNameReservation
|
||||
|
||||
// UpdatedAt stores the account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether RenameRaceNameInput is structurally complete.
|
||||
func (input RenameRaceNameInput) Validate() error {
|
||||
if err := input.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input user id: %w", err)
|
||||
}
|
||||
if err := input.CurrentCanonicalKey.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input current canonical key: %w", err)
|
||||
}
|
||||
if err := input.NewRaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input race name: %w", err)
|
||||
}
|
||||
if err := input.NewReservation.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input reservation: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("rename race name input updated at", input.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if input.NewReservation.UserID != input.UserID {
|
||||
return fmt.Errorf("rename race name input reservation user id must match user id")
|
||||
}
|
||||
if input.NewReservation.RaceName != input.NewRaceName {
|
||||
return fmt.Errorf("rename race name input reservation race name must match new race name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserAccountStore persists source-of-truth user-account records and their
|
||||
// exact lookup mappings.
|
||||
type UserAccountStore interface {
|
||||
// Create stores one new account record. Implementations must wrap
|
||||
// ErrConflict when the user id, e-mail, or exact race-name lookup already
|
||||
// exists.
|
||||
Create(ctx context.Context, input CreateAccountInput) error
|
||||
|
||||
// GetByUserID returns the stored account identified by userID.
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error)
|
||||
|
||||
// GetByEmail returns the stored account identified by the normalized e-mail
|
||||
// address.
|
||||
GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error)
|
||||
|
||||
// GetByRaceName returns the stored account identified by the exact stored
|
||||
// race name.
|
||||
GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error)
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
// account.
|
||||
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
|
||||
|
||||
// RenameRaceName replaces the stored race name of userID and swaps the
|
||||
// exact race-name lookup atomically. Implementations must wrap ErrConflict
|
||||
// when newRaceName is already owned by another account.
|
||||
RenameRaceName(ctx context.Context, input RenameRaceNameInput) error
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
Update(ctx context.Context, record account.UserAccount) error
|
||||
}
|
||||
|
||||
// RaceNameReservationStore persists source-of-truth race-name reservations.
|
||||
type RaceNameReservationStore interface {
|
||||
// Create stores one new race-name reservation keyed by its canonical
|
||||
// uniqueness key. Implementations must wrap ErrConflict when the canonical
|
||||
// key is already reserved.
|
||||
Create(ctx context.Context, record account.RaceNameReservation) error
|
||||
|
||||
// GetByCanonicalKey returns the stored reservation identified by key.
|
||||
GetByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) (account.RaceNameReservation, error)
|
||||
|
||||
// DeleteByCanonicalKey removes the reservation identified by key.
|
||||
DeleteByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) error
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
)
|
||||
|
||||
// AuthResolutionKind identifies the coarse auth-facing resolution state of one
|
||||
// e-mail subject.
|
||||
type AuthResolutionKind string
|
||||
|
||||
const (
|
||||
// AuthResolutionKindExisting reports that the e-mail belongs to an existing
|
||||
// account.
|
||||
AuthResolutionKindExisting AuthResolutionKind = "existing"
|
||||
|
||||
// AuthResolutionKindCreatable reports that the e-mail is not blocked and no
|
||||
// account exists yet.
|
||||
AuthResolutionKindCreatable AuthResolutionKind = "creatable"
|
||||
|
||||
// AuthResolutionKindBlocked reports that the e-mail subject is blocked.
|
||||
AuthResolutionKindBlocked AuthResolutionKind = "blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether AuthResolutionKind belongs to the supported
|
||||
// auth-facing vocabulary.
|
||||
func (kind AuthResolutionKind) IsKnown() bool {
|
||||
switch kind {
|
||||
case AuthResolutionKindExisting, AuthResolutionKindCreatable, AuthResolutionKindBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveByEmailResult stores the coarse auth-facing state of one e-mail
|
||||
// subject.
|
||||
type ResolveByEmailResult struct {
|
||||
// Kind stores the coarse resolution state.
|
||||
Kind AuthResolutionKind
|
||||
|
||||
// UserID is present only when Kind is AuthResolutionKindExisting.
|
||||
UserID common.UserID
|
||||
|
||||
// BlockReasonCode is present only when Kind is AuthResolutionKindBlocked.
|
||||
BlockReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether ResolveByEmailResult satisfies the auth-facing
|
||||
// invariant set.
|
||||
func (result ResolveByEmailResult) Validate() error {
|
||||
if !result.Kind.IsKnown() {
|
||||
return fmt.Errorf("resolve-by-email result kind %q is unsupported", result.Kind)
|
||||
}
|
||||
|
||||
switch result.Kind {
|
||||
case AuthResolutionKindExisting:
|
||||
if err := result.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("resolve-by-email result user id: %w", err)
|
||||
}
|
||||
if !result.BlockReasonCode.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result block reason code must be empty for existing outcome")
|
||||
}
|
||||
case AuthResolutionKindCreatable:
|
||||
if !result.UserID.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result user id must be empty for creatable outcome")
|
||||
}
|
||||
if !result.BlockReasonCode.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result block reason code must be empty for creatable outcome")
|
||||
}
|
||||
case AuthResolutionKindBlocked:
|
||||
if !result.UserID.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result user id must be empty for blocked outcome")
|
||||
}
|
||||
if err := result.BlockReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("resolve-by-email result block reason code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureByEmailOutcome identifies the coarse auth-facing ensure result.
|
||||
type EnsureByEmailOutcome string
|
||||
|
||||
const (
|
||||
// EnsureByEmailOutcomeExisting reports that the e-mail already belongs to an
|
||||
// existing account.
|
||||
EnsureByEmailOutcomeExisting EnsureByEmailOutcome = "existing"
|
||||
|
||||
// EnsureByEmailOutcomeCreated reports that a new account was created.
|
||||
EnsureByEmailOutcomeCreated EnsureByEmailOutcome = "created"
|
||||
|
||||
// EnsureByEmailOutcomeBlocked reports that creation or reuse is blocked by
|
||||
// policy.
|
||||
EnsureByEmailOutcomeBlocked EnsureByEmailOutcome = "blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether EnsureByEmailOutcome belongs to the supported
|
||||
// auth-facing vocabulary.
|
||||
func (outcome EnsureByEmailOutcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated, EnsureByEmailOutcomeBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureByEmailInput stores the complete create payload required for atomic
|
||||
// ensure-by-email behavior.
|
||||
type EnsureByEmailInput struct {
|
||||
// Email stores the exact normalized e-mail subject addressed by the ensure
|
||||
// call.
|
||||
Email common.Email
|
||||
|
||||
// Account stores the fully initialized account that should be persisted when
|
||||
// the e-mail does not yet exist and is not blocked.
|
||||
Account account.UserAccount
|
||||
|
||||
// Entitlement stores the initial current entitlement snapshot for the new
|
||||
// account.
|
||||
Entitlement entitlement.CurrentSnapshot
|
||||
|
||||
// EntitlementRecord stores the initial entitlement history record that must
|
||||
// be created atomically with Entitlement.
|
||||
EntitlementRecord entitlement.PeriodRecord
|
||||
|
||||
// Reservation stores the canonical race-name reservation for Account.
|
||||
Reservation account.RaceNameReservation
|
||||
}
|
||||
|
||||
// Validate reports whether EnsureByEmailInput is structurally complete.
|
||||
func (input EnsureByEmailInput) Validate() error {
|
||||
if err := input.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input email: %w", err)
|
||||
}
|
||||
if err := input.Account.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input account: %w", err)
|
||||
}
|
||||
if err := input.Entitlement.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input entitlement snapshot: %w", err)
|
||||
}
|
||||
if err := input.EntitlementRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record: %w", err)
|
||||
}
|
||||
if err := input.Reservation.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input reservation: %w", err)
|
||||
}
|
||||
if input.Account.Email != input.Email {
|
||||
return fmt.Errorf("ensure-by-email input account email must match request email")
|
||||
}
|
||||
if input.Account.UserID != input.Entitlement.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match entitlement user id")
|
||||
}
|
||||
if input.Account.UserID != input.EntitlementRecord.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match entitlement record user id")
|
||||
}
|
||||
if input.Account.UserID != input.Reservation.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match reservation user id")
|
||||
}
|
||||
if input.Account.RaceName != input.Reservation.RaceName {
|
||||
return fmt.Errorf("ensure-by-email input account race name must match reservation race name")
|
||||
}
|
||||
if input.EntitlementRecord.PlanCode != input.Entitlement.PlanCode {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record plan code must match entitlement snapshot plan code")
|
||||
}
|
||||
if input.EntitlementRecord.Source != input.Entitlement.Source {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record source must match entitlement snapshot source")
|
||||
}
|
||||
if input.EntitlementRecord.Actor != input.Entitlement.Actor {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record actor must match entitlement snapshot actor")
|
||||
}
|
||||
if input.EntitlementRecord.ReasonCode != input.Entitlement.ReasonCode {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record reason code must match entitlement snapshot reason code")
|
||||
}
|
||||
if !input.EntitlementRecord.StartsAt.Equal(input.Entitlement.StartsAt) {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record starts at must match entitlement snapshot starts at")
|
||||
}
|
||||
if !equalOptionalTimes(input.EntitlementRecord.EndsAt, input.Entitlement.EndsAt) {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record ends at must match entitlement snapshot ends at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureByEmailResult stores the coarse auth-facing outcome of an atomic
|
||||
// ensure-by-email call.
|
||||
type EnsureByEmailResult struct {
|
||||
// Outcome stores the coarse ensure result.
|
||||
Outcome EnsureByEmailOutcome
|
||||
|
||||
// UserID is present only for existing or created outcomes.
|
||||
UserID common.UserID
|
||||
|
||||
// BlockReasonCode is present only for the blocked outcome.
|
||||
BlockReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether EnsureByEmailResult satisfies the auth-facing
|
||||
// invariant set.
|
||||
func (result EnsureByEmailResult) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("ensure-by-email result outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
|
||||
switch result.Outcome {
|
||||
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated:
|
||||
if err := result.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email result user id: %w", err)
|
||||
}
|
||||
if !result.BlockReasonCode.IsZero() {
|
||||
return fmt.Errorf("ensure-by-email result block reason code must be empty for existing or created outcome")
|
||||
}
|
||||
case EnsureByEmailOutcomeBlocked:
|
||||
if !result.UserID.IsZero() {
|
||||
return fmt.Errorf("ensure-by-email result user id must be empty for blocked outcome")
|
||||
}
|
||||
if err := result.BlockReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email result block reason code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthBlockOutcome identifies the coarse result of blocking one auth subject.
|
||||
type AuthBlockOutcome string
|
||||
|
||||
const (
|
||||
// AuthBlockOutcomeBlocked reports that the current mutation created a new
|
||||
// block record.
|
||||
AuthBlockOutcomeBlocked AuthBlockOutcome = "blocked"
|
||||
|
||||
// AuthBlockOutcomeAlreadyBlocked reports that the block already existed.
|
||||
AuthBlockOutcomeAlreadyBlocked AuthBlockOutcome = "already_blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether AuthBlockOutcome belongs to the supported
|
||||
// auth-facing vocabulary.
|
||||
func (outcome AuthBlockOutcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case AuthBlockOutcomeBlocked, AuthBlockOutcomeAlreadyBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BlockByUserIDInput stores one auth-facing block request addressed by stable
|
||||
// user identifier.
|
||||
type BlockByUserIDInput struct {
|
||||
// UserID identifies the account that must be blocked.
|
||||
UserID common.UserID
|
||||
|
||||
// ReasonCode stores the machine-readable block reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// BlockedAt stores the timestamp applied to the blocked e-mail subject
|
||||
// record when a new block is created.
|
||||
BlockedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether BlockByUserIDInput is structurally complete.
|
||||
func (input BlockByUserIDInput) Validate() error {
|
||||
if err := input.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-user-id input user id: %w", err)
|
||||
}
|
||||
if err := input.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-user-id input reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("block-by-user-id input blocked at", input.BlockedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockByEmailInput stores one auth-facing block request addressed by exact
|
||||
// normalized e-mail subject.
|
||||
type BlockByEmailInput struct {
|
||||
// Email identifies the e-mail subject that must be blocked.
|
||||
Email common.Email
|
||||
|
||||
// ReasonCode stores the machine-readable block reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// BlockedAt stores the timestamp applied to the blocked e-mail subject
|
||||
// record when a new block is created.
|
||||
BlockedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether BlockByEmailInput is structurally complete.
|
||||
func (input BlockByEmailInput) Validate() error {
|
||||
if err := input.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-email input email: %w", err)
|
||||
}
|
||||
if err := input.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-email input reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("block-by-email input blocked at", input.BlockedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockResult stores the coarse auth-facing result of a block mutation.
|
||||
type BlockResult struct {
|
||||
// Outcome reports whether a new block was applied or already existed.
|
||||
Outcome AuthBlockOutcome
|
||||
|
||||
// UserID stores the resolved account when the blocked subject belongs to one
|
||||
// existing user.
|
||||
UserID common.UserID
|
||||
}
|
||||
|
||||
// Validate reports whether BlockResult satisfies the auth-facing invariant
|
||||
// set.
|
||||
func (result BlockResult) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("block result outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
if !result.UserID.IsZero() {
|
||||
if err := result.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("block result user id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthDirectoryStore performs the narrow set of atomic auth-facing reads and
|
||||
// mutations that must not observe inconsistent cross-key Redis state.
|
||||
type AuthDirectoryStore interface {
|
||||
// ResolveByEmail returns the current coarse auth-facing resolution state for
|
||||
// email.
|
||||
ResolveByEmail(ctx context.Context, email common.Email) (ResolveByEmailResult, error)
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
// account.
|
||||
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
|
||||
|
||||
// EnsureByEmail returns an existing user, creates a new one, or reports a
|
||||
// blocked outcome atomically for one e-mail subject.
|
||||
EnsureByEmail(ctx context.Context, input EnsureByEmailInput) (EnsureByEmailResult, error)
|
||||
|
||||
// BlockByUserID applies a block to the account identified by userID.
|
||||
BlockByUserID(ctx context.Context, input BlockByUserIDInput) (BlockResult, error)
|
||||
|
||||
// BlockByEmail applies a block to email even when no account exists yet.
|
||||
BlockByEmail(ctx context.Context, input BlockByEmailInput) (BlockResult, error)
|
||||
}
|
||||
|
||||
func equalOptionalTimes(left *time.Time, right *time.Time) bool {
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return true
|
||||
case left == nil || right == nil:
|
||||
return false
|
||||
default:
|
||||
return left.Equal(*right)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/user/internal/domain/authblock"
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// BlockedEmailStore persists the dedicated blocked-email-subject model used by
|
||||
// auth-facing flows.
|
||||
type BlockedEmailStore interface {
|
||||
// GetByEmail returns the blocked-email subject for email.
|
||||
GetByEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error)
|
||||
|
||||
// Upsert stores or replaces the blocked-email subject for record.Email.
|
||||
Upsert(ctx context.Context, record authblock.BlockedEmailSubject) error
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ports
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock returns the current wall-clock time used by timestamped mutations.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// DeclaredCountryChangedEventType identifies declared-country change events
|
||||
// in the shared auxiliary event stream.
|
||||
DeclaredCountryChangedEventType = "user.declared_country.changed"
|
||||
)
|
||||
|
||||
// DeclaredCountryChangedEvent stores one auxiliary declared-country change
|
||||
// notification emitted after a successful source-of-truth update.
|
||||
type DeclaredCountryChangedEvent struct {
|
||||
// UserID identifies the user whose current declared country changed.
|
||||
UserID common.UserID
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// DeclaredCountry stores the latest effective declared country.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// UpdatedAt stores the persisted account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// Source stores the machine-readable upstream mutation source.
|
||||
Source common.Source
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event DeclaredCountryChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("declared-country changed event", event.UserID, event.UpdatedAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := event.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("declared-country changed event declared country: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeclaredCountryChangedPublisher publishes auxiliary declared-country change
|
||||
// notifications after source-of-truth account updates.
|
||||
type DeclaredCountryChangedPublisher interface {
|
||||
// PublishDeclaredCountryChanged propagates one committed declared-country
|
||||
// change event.
|
||||
PublishDeclaredCountryChanged(ctx context.Context, event DeclaredCountryChangedEvent) error
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
const (
|
||||
// ProfileChangedEventType identifies profile-change events in the shared
|
||||
// auxiliary event stream.
|
||||
ProfileChangedEventType = "user.profile.changed"
|
||||
|
||||
// SettingsChangedEventType identifies settings-change events in the shared
|
||||
// auxiliary event stream.
|
||||
SettingsChangedEventType = "user.settings.changed"
|
||||
|
||||
// EntitlementChangedEventType identifies entitlement-change events in the
|
||||
// shared auxiliary event stream.
|
||||
EntitlementChangedEventType = "user.entitlement.changed"
|
||||
|
||||
// SanctionChangedEventType identifies sanction-change events in the shared
|
||||
// auxiliary event stream.
|
||||
SanctionChangedEventType = "user.sanction.changed"
|
||||
|
||||
// LimitChangedEventType identifies limit-change events in the shared
|
||||
// auxiliary event stream.
|
||||
LimitChangedEventType = "user.limit.changed"
|
||||
)
|
||||
|
||||
// ProfileChangedOperation identifies one profile-change event kind.
|
||||
type ProfileChangedOperation string
|
||||
|
||||
const (
|
||||
// ProfileChangedOperationInitialized reports the initial account
|
||||
// materialization performed during auth-driven user creation.
|
||||
ProfileChangedOperationInitialized ProfileChangedOperation = "initialized"
|
||||
|
||||
// ProfileChangedOperationUpdated reports a later self-service profile
|
||||
// update.
|
||||
ProfileChangedOperationUpdated ProfileChangedOperation = "updated"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen profile-change
|
||||
// event vocabulary.
|
||||
func (operation ProfileChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case ProfileChangedOperationInitialized, ProfileChangedOperationUpdated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SettingsChangedOperation identifies one settings-change event kind.
|
||||
type SettingsChangedOperation string
|
||||
|
||||
const (
|
||||
// SettingsChangedOperationInitialized reports the initial account settings
|
||||
// materialization performed during auth-driven user creation.
|
||||
SettingsChangedOperationInitialized SettingsChangedOperation = "initialized"
|
||||
|
||||
// SettingsChangedOperationUpdated reports a later self-service settings
|
||||
// update.
|
||||
SettingsChangedOperationUpdated SettingsChangedOperation = "updated"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen settings-change
|
||||
// event vocabulary.
|
||||
func (operation SettingsChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case SettingsChangedOperationInitialized, SettingsChangedOperationUpdated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementChangedOperation identifies one entitlement-change event kind.
|
||||
type EntitlementChangedOperation string
|
||||
|
||||
const (
|
||||
// EntitlementChangedOperationInitialized reports the initial free snapshot
|
||||
// created for a new user.
|
||||
EntitlementChangedOperationInitialized EntitlementChangedOperation = "initialized"
|
||||
|
||||
// EntitlementChangedOperationGranted reports an explicit paid grant.
|
||||
EntitlementChangedOperationGranted EntitlementChangedOperation = "granted"
|
||||
|
||||
// EntitlementChangedOperationExtended reports an explicit paid extension.
|
||||
EntitlementChangedOperationExtended EntitlementChangedOperation = "extended"
|
||||
|
||||
// EntitlementChangedOperationRevoked reports an explicit paid revoke.
|
||||
EntitlementChangedOperationRevoked EntitlementChangedOperation = "revoked"
|
||||
|
||||
// EntitlementChangedOperationExpiredRepaired reports lazy repair of a
|
||||
// naturally expired finite paid snapshot.
|
||||
EntitlementChangedOperationExpiredRepaired EntitlementChangedOperation = "expired_repaired"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen entitlement-change
|
||||
// event vocabulary.
|
||||
func (operation EntitlementChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case EntitlementChangedOperationInitialized,
|
||||
EntitlementChangedOperationGranted,
|
||||
EntitlementChangedOperationExtended,
|
||||
EntitlementChangedOperationRevoked,
|
||||
EntitlementChangedOperationExpiredRepaired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SanctionChangedOperation identifies one sanction-change event kind.
|
||||
type SanctionChangedOperation string
|
||||
|
||||
const (
|
||||
// SanctionChangedOperationApplied reports a new active sanction.
|
||||
SanctionChangedOperationApplied SanctionChangedOperation = "applied"
|
||||
|
||||
// SanctionChangedOperationRemoved reports explicit removal of an active
|
||||
// sanction.
|
||||
SanctionChangedOperationRemoved SanctionChangedOperation = "removed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen sanction-change
|
||||
// event vocabulary.
|
||||
func (operation SanctionChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case SanctionChangedOperationApplied, SanctionChangedOperationRemoved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LimitChangedOperation identifies one limit-change event kind.
|
||||
type LimitChangedOperation string
|
||||
|
||||
const (
|
||||
// LimitChangedOperationSet reports a new or replacement active limit.
|
||||
LimitChangedOperationSet LimitChangedOperation = "set"
|
||||
|
||||
// LimitChangedOperationRemoved reports explicit removal of an active limit.
|
||||
LimitChangedOperationRemoved LimitChangedOperation = "removed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen limit-change event
|
||||
// vocabulary.
|
||||
func (operation LimitChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case LimitChangedOperationSet, LimitChangedOperationRemoved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileChangedEvent stores one post-commit auxiliary profile-change event.
|
||||
type ProfileChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the profile-change event kind.
|
||||
Operation ProfileChangedOperation
|
||||
|
||||
// RaceName stores the latest exact race name after the commit.
|
||||
RaceName common.RaceName
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event ProfileChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("profile changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("profile changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if err := event.RaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("profile changed event race name: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SettingsChangedEvent stores one post-commit auxiliary settings-change event.
|
||||
type SettingsChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the settings-change event kind.
|
||||
Operation SettingsChangedOperation
|
||||
|
||||
// PreferredLanguage stores the latest preferred language after the commit.
|
||||
PreferredLanguage common.LanguageTag
|
||||
|
||||
// TimeZone stores the latest time-zone name after the commit.
|
||||
TimeZone common.TimeZoneName
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event SettingsChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("settings changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("settings changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if err := event.PreferredLanguage.Validate(); err != nil {
|
||||
return fmt.Errorf("settings changed event preferred language: %w", err)
|
||||
}
|
||||
if err := event.TimeZone.Validate(); err != nil {
|
||||
return fmt.Errorf("settings changed event time zone: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntitlementChangedEvent stores one post-commit auxiliary entitlement-change
|
||||
// event.
|
||||
type EntitlementChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the entitlement-change event kind.
|
||||
Operation EntitlementChangedOperation
|
||||
|
||||
// PlanCode stores the effective plan after the commit.
|
||||
PlanCode entitlement.PlanCode
|
||||
|
||||
// IsPaid stores the effective paid/free flag after the commit.
|
||||
IsPaid bool
|
||||
|
||||
// StartsAt stores when the effective entitlement state started.
|
||||
StartsAt time.Time
|
||||
|
||||
// EndsAt stores the optional finite paid expiry.
|
||||
EndsAt *time.Time
|
||||
|
||||
// ReasonCode stores the mutation reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// UpdatedAt stores when the current entitlement snapshot was recomputed.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event EntitlementChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("entitlement changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("entitlement changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if !event.PlanCode.IsKnown() {
|
||||
return fmt.Errorf("entitlement changed event plan code %q is unsupported", event.PlanCode)
|
||||
}
|
||||
if event.IsPaid != event.PlanCode.IsPaid() {
|
||||
return fmt.Errorf("entitlement changed event paid flag must match plan code %q", event.PlanCode)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement changed event starts at", event.StartsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.PlanCode.HasFiniteExpiry() {
|
||||
if event.EndsAt == nil {
|
||||
return fmt.Errorf("entitlement changed event ends at must be present for plan code %q", event.PlanCode)
|
||||
}
|
||||
if !event.EndsAt.After(event.StartsAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
} else if event.EndsAt != nil {
|
||||
return fmt.Errorf("entitlement changed event ends at must be empty for plan code %q", event.PlanCode)
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement changed event reason code: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement changed event actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement changed event updated at", event.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanctionChangedEvent stores one post-commit auxiliary sanction-change event.
|
||||
type SanctionChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the sanction-change event kind.
|
||||
Operation SanctionChangedOperation
|
||||
|
||||
// SanctionCode stores the affected sanction code.
|
||||
SanctionCode policy.SanctionCode
|
||||
|
||||
// Scope stores the machine-readable sanction scope.
|
||||
Scope common.Scope
|
||||
|
||||
// ReasonCode stores the mutation reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the sanction became effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned sanction expiry.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores the optional sanction removal timestamp.
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event SanctionChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("sanction changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("sanction changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if !event.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction changed event sanction code %q is unsupported", event.SanctionCode)
|
||||
}
|
||||
if err := event.Scope.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction changed event scope: %w", err)
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction changed event reason code: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction changed event actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("sanction changed event applied at", event.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
|
||||
return fmt.Errorf("sanction changed event removed at must not be before applied at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LimitChangedEvent stores one post-commit auxiliary limit-change event.
|
||||
type LimitChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the limit-change event kind.
|
||||
Operation LimitChangedOperation
|
||||
|
||||
// LimitCode stores the affected limit code.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// Value stores the active limit value when the operation is `set`.
|
||||
Value *int
|
||||
|
||||
// ReasonCode stores the mutation reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the limit became effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned limit expiry.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores the optional explicit limit removal timestamp.
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event LimitChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("limit changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("limit changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if !event.LimitCode.IsSupported() {
|
||||
return fmt.Errorf("limit changed event limit code %q is unsupported", event.LimitCode)
|
||||
}
|
||||
switch event.Operation {
|
||||
case LimitChangedOperationSet:
|
||||
if event.Value == nil {
|
||||
return fmt.Errorf("limit changed event value must be present for operation %q", event.Operation)
|
||||
}
|
||||
if *event.Value < 0 {
|
||||
return fmt.Errorf("limit changed event value must not be negative")
|
||||
}
|
||||
case LimitChangedOperationRemoved:
|
||||
if event.Value != nil && *event.Value < 0 {
|
||||
return fmt.Errorf("limit changed event value must not be negative")
|
||||
}
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit changed event reason code: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("limit changed event actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("limit changed event applied at", event.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
|
||||
return fmt.Errorf("limit changed event removed at must not be before applied at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProfileChangedPublisher publishes auxiliary profile-change notifications.
|
||||
type ProfileChangedPublisher interface {
|
||||
// PublishProfileChanged propagates one committed profile-change event.
|
||||
PublishProfileChanged(ctx context.Context, event ProfileChangedEvent) error
|
||||
}
|
||||
|
||||
// SettingsChangedPublisher publishes auxiliary settings-change notifications.
|
||||
type SettingsChangedPublisher interface {
|
||||
// PublishSettingsChanged propagates one committed settings-change event.
|
||||
PublishSettingsChanged(ctx context.Context, event SettingsChangedEvent) error
|
||||
}
|
||||
|
||||
// EntitlementChangedPublisher publishes auxiliary entitlement-change
|
||||
// notifications.
|
||||
type EntitlementChangedPublisher interface {
|
||||
// PublishEntitlementChanged propagates one committed entitlement-change
|
||||
// event.
|
||||
PublishEntitlementChanged(ctx context.Context, event EntitlementChangedEvent) error
|
||||
}
|
||||
|
||||
// SanctionChangedPublisher publishes auxiliary sanction-change notifications.
|
||||
type SanctionChangedPublisher interface {
|
||||
// PublishSanctionChanged propagates one committed sanction-change event.
|
||||
PublishSanctionChanged(ctx context.Context, event SanctionChangedEvent) error
|
||||
}
|
||||
|
||||
// LimitChangedPublisher publishes auxiliary limit-change notifications.
|
||||
type LimitChangedPublisher interface {
|
||||
// PublishLimitChanged propagates one committed limit-change event.
|
||||
PublishLimitChanged(ctx context.Context, event LimitChangedEvent) error
|
||||
}
|
||||
|
||||
func validateEventEnvelope(name string, userID common.UserID, occurredAt time.Time, source common.Source, traceID string) error {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return fmt.Errorf("%s user id: %w", name, err)
|
||||
}
|
||||
if err := common.ValidateTimestamp(name+" occurred at", occurredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := source.Validate(); err != nil {
|
||||
return fmt.Errorf("%s source: %w", name, err)
|
||||
}
|
||||
if traceID != "" {
|
||||
if strings.TrimSpace(traceID) != traceID {
|
||||
return fmt.Errorf("%s trace id must not contain surrounding whitespace", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
)
|
||||
|
||||
// EntitlementHistoryStore persists immutable entitlement period records and
|
||||
// later close-state updates.
|
||||
type EntitlementHistoryStore interface {
|
||||
// Create stores one new entitlement period history record. Implementations
|
||||
// must wrap ErrConflict when record.RecordID already exists.
|
||||
Create(ctx context.Context, record entitlement.PeriodRecord) error
|
||||
|
||||
// GetByRecordID returns the entitlement period history record identified by
|
||||
// recordID.
|
||||
GetByRecordID(ctx context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error)
|
||||
|
||||
// ListByUserID returns every entitlement period history record owned by
|
||||
// userID.
|
||||
ListByUserID(ctx context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error)
|
||||
|
||||
// Update replaces one stored entitlement period history record.
|
||||
Update(ctx context.Context, record entitlement.PeriodRecord) error
|
||||
}
|
||||
|
||||
// EntitlementSnapshotStore persists the read-optimized current entitlement
|
||||
// snapshot.
|
||||
type EntitlementSnapshotStore interface {
|
||||
// GetByUserID returns the current entitlement snapshot for userID.
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
|
||||
|
||||
// Put stores the current entitlement snapshot for record.UserID.
|
||||
Put(ctx context.Context, record entitlement.CurrentSnapshot) error
|
||||
}
|
||||
|
||||
// GrantEntitlementInput stores one atomic transition from a current free
|
||||
// entitlement state to a current paid state.
|
||||
type GrantEntitlementInput struct {
|
||||
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
|
||||
// current before the mutation commits.
|
||||
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// ExpectedCurrentRecord stores the current effective free period that must
|
||||
// still be current before the mutation commits.
|
||||
ExpectedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
|
||||
// is applied.
|
||||
UpdatedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// NewRecord stores the new paid entitlement history segment.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the new current effective entitlement snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether GrantEntitlementInput is structurally complete.
|
||||
func (input GrantEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input expected current snapshot: %w", err)
|
||||
}
|
||||
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input expected current record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input updated current record: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("grant entitlement input all records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
|
||||
return fmt.Errorf("grant entitlement input updated current record must preserve record id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtendEntitlementInput stores one atomic extension of a current finite paid
|
||||
// entitlement state.
|
||||
type ExtendEntitlementInput struct {
|
||||
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
|
||||
// current before the mutation commits.
|
||||
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// NewRecord stores the appended entitlement history segment that extends the
|
||||
// current paid state.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the replacement current effective entitlement snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether ExtendEntitlementInput is structurally complete.
|
||||
func (input ExtendEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement input expected current snapshot: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("extend entitlement input all records must belong to the same user id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeEntitlementInput stores one atomic transition from a current paid
|
||||
// entitlement state to a new free state.
|
||||
type RevokeEntitlementInput struct {
|
||||
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
|
||||
// current before the mutation commits.
|
||||
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// ExpectedCurrentRecord stores the current effective paid period that must
|
||||
// still be current before the mutation commits.
|
||||
ExpectedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
|
||||
// is applied.
|
||||
UpdatedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// NewRecord stores the newly created free entitlement period.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the replacement current effective free snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeEntitlementInput is structurally complete.
|
||||
func (input RevokeEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input expected current snapshot: %w", err)
|
||||
}
|
||||
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input expected current record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input updated current record: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("revoke entitlement input all records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
|
||||
return fmt.Errorf("revoke entitlement input updated current record must preserve record id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepairExpiredEntitlementInput stores one atomic lazy-repair transition from
|
||||
// an expired finite paid snapshot to a materialized free state.
|
||||
type RepairExpiredEntitlementInput struct {
|
||||
// ExpectedExpiredSnapshot stores the exact expired snapshot that must still
|
||||
// be current before the repair commits.
|
||||
ExpectedExpiredSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// NewRecord stores the newly created free entitlement period.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the replacement current effective free snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether RepairExpiredEntitlementInput is structurally
|
||||
// complete.
|
||||
func (input RepairExpiredEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedExpiredSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement input expected expired snapshot: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedExpiredSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedExpiredSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("repair expired entitlement input all records must belong to the same user id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntitlementLifecycleStore persists atomic entitlement timeline transitions
|
||||
// that must keep history and current snapshot consistent.
|
||||
type EntitlementLifecycleStore interface {
|
||||
// Grant atomically closes the current free period, creates a new paid
|
||||
// period, and replaces the current snapshot.
|
||||
Grant(ctx context.Context, input GrantEntitlementInput) error
|
||||
|
||||
// Extend atomically appends one paid-history segment and replaces the
|
||||
// current snapshot.
|
||||
Extend(ctx context.Context, input ExtendEntitlementInput) error
|
||||
|
||||
// Revoke atomically closes the current paid period, creates a new free
|
||||
// period, and replaces the current snapshot.
|
||||
Revoke(ctx context.Context, input RevokeEntitlementInput) error
|
||||
|
||||
// RepairExpired atomically replaces one expired finite paid snapshot with a
|
||||
// materialized free state.
|
||||
RepairExpired(ctx context.Context, input RepairExpiredEntitlementInput) error
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package ports defines the storage-agnostic boundaries used by the user
|
||||
// service.
|
||||
package ports
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound reports that a requested source-of-truth record does not
|
||||
// exist in the dependency behind the port.
|
||||
ErrNotFound = errors.New("ports: record not found")
|
||||
|
||||
// ErrConflict reports that a create or update cannot be applied because the
|
||||
// dependency state conflicts with the requested mutation.
|
||||
ErrConflict = errors.New("ports: conflict")
|
||||
|
||||
// ErrInvalidPageToken reports that a supplied pagination token cannot be
|
||||
// decoded or does not match the expected filter set.
|
||||
ErrInvalidPageToken = errors.New("ports: invalid page token")
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRaceNameConflict reports that a mutation specifically failed because a
|
||||
// race-name lookup or canonical reservation is already owned by another
|
||||
// user. The sentinel still matches ErrConflict via errors.Is so callers can
|
||||
// preserve the stable public conflict semantics while collecting more
|
||||
// precise observability.
|
||||
ErrRaceNameConflict = fmt.Errorf("%w: race name conflict", ErrConflict)
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
// IDGenerator creates new user identifiers and generated initial race names.
|
||||
type IDGenerator interface {
|
||||
// NewUserID returns one newly generated stable user identifier.
|
||||
NewUserID() (common.UserID, error)
|
||||
|
||||
// NewInitialRaceName returns one generated initial race name in the
|
||||
// `player-<shortid>` form.
|
||||
NewInitialRaceName() (common.RaceName, error)
|
||||
|
||||
// NewEntitlementRecordID returns one newly generated entitlement history
|
||||
// record identifier.
|
||||
NewEntitlementRecordID() (entitlement.EntitlementRecordID, error)
|
||||
|
||||
// NewSanctionRecordID returns one newly generated sanction history record
|
||||
// identifier.
|
||||
NewSanctionRecordID() (policy.SanctionRecordID, error)
|
||||
|
||||
// NewLimitRecordID returns one newly generated limit history record
|
||||
// identifier.
|
||||
NewLimitRecordID() (policy.LimitRecordID, error)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
// SanctionStore persists sanction history records and later remove-state
|
||||
// updates.
|
||||
type SanctionStore interface {
|
||||
// Create stores one new sanction history record. Implementations must wrap
|
||||
// ErrConflict when record.RecordID already exists.
|
||||
Create(ctx context.Context, record policy.SanctionRecord) error
|
||||
|
||||
// GetByRecordID returns the sanction history record identified by recordID.
|
||||
GetByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error)
|
||||
|
||||
// ListByUserID returns every sanction history record owned by userID.
|
||||
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error)
|
||||
|
||||
// Update replaces one stored sanction history record.
|
||||
Update(ctx context.Context, record policy.SanctionRecord) error
|
||||
}
|
||||
|
||||
// LimitStore persists user-specific limit history records and later
|
||||
// remove-state updates.
|
||||
type LimitStore interface {
|
||||
// Create stores one new limit history record. Implementations must wrap
|
||||
// ErrConflict when record.RecordID already exists.
|
||||
Create(ctx context.Context, record policy.LimitRecord) error
|
||||
|
||||
// GetByRecordID returns the limit history record identified by recordID.
|
||||
GetByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error)
|
||||
|
||||
// ListByUserID returns every limit history record owned by userID.
|
||||
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error)
|
||||
|
||||
// Update replaces one stored limit history record.
|
||||
Update(ctx context.Context, record policy.LimitRecord) error
|
||||
}
|
||||
|
||||
// ApplySanctionInput stores one atomic creation of a new active sanction.
|
||||
type ApplySanctionInput struct {
|
||||
// NewRecord stores the sanction history record that must become active.
|
||||
NewRecord policy.SanctionRecord
|
||||
}
|
||||
|
||||
// Validate reports whether ApplySanctionInput is structurally complete.
|
||||
func (input ApplySanctionInput) Validate() error {
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("apply sanction input new record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSanctionInput stores one atomic removal of the current active
|
||||
// sanction for one `user_id + sanction_code`.
|
||||
type RemoveSanctionInput struct {
|
||||
// ExpectedActiveRecord stores the exact sanction record that must still be
|
||||
// active before the mutation commits.
|
||||
ExpectedActiveRecord policy.SanctionRecord
|
||||
|
||||
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
|
||||
// applied.
|
||||
UpdatedRecord policy.SanctionRecord
|
||||
}
|
||||
|
||||
// Validate reports whether RemoveSanctionInput is structurally complete.
|
||||
func (input RemoveSanctionInput) Validate() error {
|
||||
if err := input.ExpectedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove sanction input expected active record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove sanction input updated record: %w", err)
|
||||
}
|
||||
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
|
||||
return fmt.Errorf("remove sanction input updated record must preserve record id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
|
||||
return fmt.Errorf("remove sanction input records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.SanctionCode != input.UpdatedRecord.SanctionCode {
|
||||
return fmt.Errorf("remove sanction input records must preserve sanction code")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLimitInput stores one atomic creation or replacement of the current
|
||||
// active limit for one `user_id + limit_code`.
|
||||
type SetLimitInput struct {
|
||||
// ExpectedActiveRecord stores the currently active limit that must still be
|
||||
// active before replacement commits. It stays nil when no active limit
|
||||
// exists yet.
|
||||
ExpectedActiveRecord *policy.LimitRecord
|
||||
|
||||
// UpdatedActiveRecord stores ExpectedActiveRecord after remove metadata is
|
||||
// applied. It stays nil when no active limit exists yet.
|
||||
UpdatedActiveRecord *policy.LimitRecord
|
||||
|
||||
// NewRecord stores the limit history record that must become active.
|
||||
NewRecord policy.LimitRecord
|
||||
}
|
||||
|
||||
// Validate reports whether SetLimitInput is structurally complete.
|
||||
func (input SetLimitInput) Validate() error {
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit input new record: %w", err)
|
||||
}
|
||||
switch {
|
||||
case input.ExpectedActiveRecord == nil && input.UpdatedActiveRecord == nil:
|
||||
return nil
|
||||
case input.ExpectedActiveRecord == nil || input.UpdatedActiveRecord == nil:
|
||||
return fmt.Errorf("set limit input active replacement records must both be present or absent")
|
||||
}
|
||||
if err := input.ExpectedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit input expected active record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit input updated active record: %w", err)
|
||||
}
|
||||
if input.ExpectedActiveRecord.RecordID != input.UpdatedActiveRecord.RecordID {
|
||||
return fmt.Errorf("set limit input updated active record must preserve record id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.UserID != input.UpdatedActiveRecord.UserID ||
|
||||
input.ExpectedActiveRecord.UserID != input.NewRecord.UserID {
|
||||
return fmt.Errorf("set limit input records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.LimitCode != input.UpdatedActiveRecord.LimitCode ||
|
||||
input.ExpectedActiveRecord.LimitCode != input.NewRecord.LimitCode {
|
||||
return fmt.Errorf("set limit input records must preserve limit code")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveLimitInput stores one atomic removal of the current active limit for
|
||||
// one `user_id + limit_code`.
|
||||
type RemoveLimitInput struct {
|
||||
// ExpectedActiveRecord stores the exact limit record that must still be
|
||||
// active before the mutation commits.
|
||||
ExpectedActiveRecord policy.LimitRecord
|
||||
|
||||
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
|
||||
// applied.
|
||||
UpdatedRecord policy.LimitRecord
|
||||
}
|
||||
|
||||
// Validate reports whether RemoveLimitInput is structurally complete.
|
||||
func (input RemoveLimitInput) Validate() error {
|
||||
if err := input.ExpectedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove limit input expected active record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove limit input updated record: %w", err)
|
||||
}
|
||||
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
|
||||
return fmt.Errorf("remove limit input updated record must preserve record id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
|
||||
return fmt.Errorf("remove limit input records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.LimitCode != input.UpdatedRecord.LimitCode {
|
||||
return fmt.Errorf("remove limit input records must preserve limit code")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PolicyLifecycleStore persists atomic sanction and limit transitions that
|
||||
// must keep history and active-slot state consistent.
|
||||
type PolicyLifecycleStore interface {
|
||||
// ApplySanction atomically creates one new active sanction record.
|
||||
ApplySanction(ctx context.Context, input ApplySanctionInput) error
|
||||
|
||||
// RemoveSanction atomically removes one active sanction record.
|
||||
RemoveSanction(ctx context.Context, input RemoveSanctionInput) error
|
||||
|
||||
// SetLimit atomically creates or replaces one active limit record.
|
||||
SetLimit(ctx context.Context, input SetLimitInput) error
|
||||
|
||||
// RemoveLimit atomically removes one active limit record.
|
||||
RemoveLimit(ctx context.Context, input RemoveLimitInput) error
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// RaceNamePolicy produces the canonical uniqueness key used to reserve one
|
||||
// replaceable race-name slot.
|
||||
type RaceNamePolicy interface {
|
||||
// CanonicalKey returns the stable reservation key for raceName. Callers are
|
||||
// expected to pass a validated raceName value.
|
||||
CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultUserListPageSize stores the frozen default page size used by the
|
||||
// trusted admin listing surface when the caller omits `page_size`.
|
||||
DefaultUserListPageSize = 50
|
||||
|
||||
// MaxUserListPageSize stores the frozen maximum page size accepted by the
|
||||
// trusted admin listing surface.
|
||||
MaxUserListPageSize = 200
|
||||
)
|
||||
|
||||
// UserListFilters stores the frozen admin-listing filter set.
|
||||
type UserListFilters struct {
|
||||
// PaidState stores the optional coarse free-versus-paid filter.
|
||||
PaidState entitlement.PaidState
|
||||
|
||||
// PaidExpiresBefore stores the optional strict upper bound for finite paid
|
||||
// expiry.
|
||||
PaidExpiresBefore *time.Time
|
||||
|
||||
// PaidExpiresAfter stores the optional strict lower bound for finite paid
|
||||
// expiry.
|
||||
PaidExpiresAfter *time.Time
|
||||
|
||||
// DeclaredCountry stores the optional current declared-country filter.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// SanctionCode stores the optional active-sanction filter.
|
||||
SanctionCode policy.SanctionCode
|
||||
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// CanLogin stores the optional derived login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
// CanCreatePrivateGame stores the optional derived private-game-create
|
||||
// eligibility filter.
|
||||
CanCreatePrivateGame *bool
|
||||
|
||||
// CanJoinGame stores the optional derived game-join eligibility filter.
|
||||
CanJoinGame *bool
|
||||
}
|
||||
|
||||
// Validate reports whether filters is structurally valid.
|
||||
func (filters UserListFilters) Validate() error {
|
||||
if !filters.PaidState.IsKnown() {
|
||||
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
|
||||
}
|
||||
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
|
||||
return fmt.Errorf("paid expires before must not be zero")
|
||||
}
|
||||
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
|
||||
return fmt.Errorf("paid expires after must not be zero")
|
||||
}
|
||||
if !filters.DeclaredCountry.IsZero() {
|
||||
if err := filters.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("declared country: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
|
||||
}
|
||||
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
|
||||
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsersInput stores one trusted admin-listing read request.
|
||||
type ListUsersInput struct {
|
||||
// PageSize stores the maximum number of ordered user identifiers returned
|
||||
// in one storage page.
|
||||
PageSize int
|
||||
|
||||
// PageToken stores the optional opaque continuation cursor.
|
||||
PageToken string
|
||||
|
||||
// Filters stores the normalized filter set bound into PageToken.
|
||||
Filters UserListFilters
|
||||
}
|
||||
|
||||
// Validate reports whether input is structurally complete.
|
||||
func (input ListUsersInput) Validate() error {
|
||||
switch {
|
||||
case input.PageSize < 1:
|
||||
return fmt.Errorf("page size must be at least 1")
|
||||
case input.PageSize > MaxUserListPageSize:
|
||||
return fmt.Errorf("page size must be at most %d", MaxUserListPageSize)
|
||||
case strings.TrimSpace(input.PageToken) != input.PageToken:
|
||||
return fmt.Errorf("page token must not contain surrounding whitespace")
|
||||
}
|
||||
if err := input.Filters.Validate(); err != nil {
|
||||
return fmt.Errorf("filters: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsersResult stores one deterministic ordered storage page of user ids.
|
||||
type ListUsersResult struct {
|
||||
// UserIDs stores the ordered user identifiers returned for the requested
|
||||
// page.
|
||||
UserIDs []common.UserID
|
||||
|
||||
// NextPageToken stores the optional opaque continuation cursor for the next
|
||||
// page.
|
||||
NextPageToken string
|
||||
}
|
||||
|
||||
// UserListStore provides deterministic ordered admin-listing pagination over
|
||||
// stored user identifiers.
|
||||
type UserListStore interface {
|
||||
// ListUserIDs returns one deterministic storage page of user identifiers.
|
||||
ListUserIDs(ctx context.Context, input ListUsersInput) (ListUsersResult, error)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Package accountview materializes the shared account aggregate view used by
|
||||
// self-service and trusted administrative reads.
|
||||
package accountview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
)
|
||||
|
||||
// ActorRefView stores transport-ready audit actor metadata.
|
||||
type ActorRefView struct {
|
||||
// Type stores the machine-readable actor type.
|
||||
Type string `json:"type"`
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// EntitlementSnapshotView stores the transport-ready current entitlement
|
||||
// snapshot of one account.
|
||||
type EntitlementSnapshotView struct {
|
||||
// PlanCode stores the effective entitlement plan code.
|
||||
PlanCode string `json:"plan_code"`
|
||||
|
||||
// IsPaid reports whether the effective plan is paid.
|
||||
IsPaid bool `json:"is_paid"`
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source string `json:"source"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the snapshot.
|
||||
Actor ActorRefView `json:"actor"`
|
||||
|
||||
// ReasonCode stores the machine-readable reason attached to the snapshot.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// StartsAt stores when the effective state started.
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
|
||||
// EndsAt stores the optional finite effective expiry.
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
|
||||
// UpdatedAt stores when the snapshot was last recomputed.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ActiveSanctionView stores one transport-ready active sanction.
|
||||
type ActiveSanctionView struct {
|
||||
// SanctionCode stores the active sanction code.
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
|
||||
// Scope stores the machine-readable sanction scope.
|
||||
Scope string `json:"scope"`
|
||||
|
||||
// ReasonCode stores the machine-readable sanction reason.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the sanction.
|
||||
Actor ActorRefView `json:"actor"`
|
||||
|
||||
// AppliedAt stores when the sanction became active.
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
|
||||
// ExpiresAt stores the optional planned sanction expiry.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ActiveLimitView stores one transport-ready active user-specific limit.
|
||||
type ActiveLimitView struct {
|
||||
// LimitCode stores the active limit code.
|
||||
LimitCode string `json:"limit_code"`
|
||||
|
||||
// Value stores the current override value.
|
||||
Value int `json:"value"`
|
||||
|
||||
// ReasonCode stores the machine-readable limit reason.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the limit.
|
||||
Actor ActorRefView `json:"actor"`
|
||||
|
||||
// AppliedAt stores when the limit became active.
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
|
||||
// ExpiresAt stores the optional planned limit expiry.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// AccountView stores the transport-ready account aggregate shared by
|
||||
// self-service and admin reads.
|
||||
type AccountView struct {
|
||||
// UserID stores the durable regular-user identifier.
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// Email stores the exact normalized login e-mail address.
|
||||
Email string `json:"email"`
|
||||
|
||||
// RaceName stores the current user-facing race name.
|
||||
RaceName string `json:"race_name"`
|
||||
|
||||
// PreferredLanguage stores the current BCP 47 preferred language.
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
|
||||
// TimeZone stores the current IANA time-zone name.
|
||||
TimeZone string `json:"time_zone"`
|
||||
|
||||
// DeclaredCountry stores the optional latest effective declared country.
|
||||
DeclaredCountry string `json:"declared_country,omitempty"`
|
||||
|
||||
// Entitlement stores the current entitlement snapshot.
|
||||
Entitlement EntitlementSnapshotView `json:"entitlement"`
|
||||
|
||||
// ActiveSanctions stores the current active sanctions sorted by code.
|
||||
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
|
||||
|
||||
// ActiveLimits stores the current active user-specific limits sorted by
|
||||
// code.
|
||||
ActiveLimits []ActiveLimitView `json:"active_limits"`
|
||||
|
||||
// CreatedAt stores when the account was created.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// UpdatedAt stores when the account was last mutated.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Aggregate stores the raw domain state that backs one shared account view.
|
||||
type Aggregate struct {
|
||||
// AccountRecord stores the current editable account record.
|
||||
AccountRecord account.UserAccount
|
||||
|
||||
// EntitlementSnapshot stores the current effective entitlement snapshot.
|
||||
EntitlementSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// ActiveSanctions stores the active sanctions sorted by code.
|
||||
ActiveSanctions []policy.SanctionRecord
|
||||
|
||||
// ActiveLimits stores the active user-specific limits sorted by code.
|
||||
ActiveLimits []policy.LimitRecord
|
||||
}
|
||||
|
||||
// HasActiveSanction reports whether aggregate currently contains code in its
|
||||
// active sanction set.
|
||||
func (aggregate Aggregate) HasActiveSanction(code policy.SanctionCode) bool {
|
||||
for _, record := range aggregate.ActiveSanctions {
|
||||
if record.SanctionCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HasActiveLimit reports whether aggregate currently contains code in its
|
||||
// active user-specific limit set.
|
||||
func (aggregate Aggregate) HasActiveLimit(code policy.LimitCode) bool {
|
||||
for _, record := range aggregate.ActiveLimits {
|
||||
if record.LimitCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// View materializes Aggregate into the shared transport-ready account view.
|
||||
func (aggregate Aggregate) View() AccountView {
|
||||
view := AccountView{
|
||||
UserID: aggregate.AccountRecord.UserID.String(),
|
||||
Email: aggregate.AccountRecord.Email.String(),
|
||||
RaceName: aggregate.AccountRecord.RaceName.String(),
|
||||
PreferredLanguage: aggregate.AccountRecord.PreferredLanguage.String(),
|
||||
TimeZone: aggregate.AccountRecord.TimeZone.String(),
|
||||
Entitlement: EntitlementSnapshotView{
|
||||
PlanCode: string(aggregate.EntitlementSnapshot.PlanCode),
|
||||
IsPaid: aggregate.EntitlementSnapshot.IsPaid,
|
||||
Source: aggregate.EntitlementSnapshot.Source.String(),
|
||||
Actor: actorRefView(aggregate.EntitlementSnapshot.Actor),
|
||||
ReasonCode: aggregate.EntitlementSnapshot.ReasonCode.String(),
|
||||
StartsAt: aggregate.EntitlementSnapshot.StartsAt.UTC(),
|
||||
EndsAt: cloneOptionalTime(aggregate.EntitlementSnapshot.EndsAt),
|
||||
UpdatedAt: aggregate.EntitlementSnapshot.UpdatedAt.UTC(),
|
||||
},
|
||||
ActiveSanctions: make([]ActiveSanctionView, 0, len(aggregate.ActiveSanctions)),
|
||||
ActiveLimits: make([]ActiveLimitView, 0, len(aggregate.ActiveLimits)),
|
||||
CreatedAt: aggregate.AccountRecord.CreatedAt.UTC(),
|
||||
UpdatedAt: aggregate.AccountRecord.UpdatedAt.UTC(),
|
||||
}
|
||||
if !aggregate.AccountRecord.DeclaredCountry.IsZero() {
|
||||
view.DeclaredCountry = aggregate.AccountRecord.DeclaredCountry.String()
|
||||
}
|
||||
|
||||
for _, sanctionRecord := range aggregate.ActiveSanctions {
|
||||
view.ActiveSanctions = append(view.ActiveSanctions, ActiveSanctionView{
|
||||
SanctionCode: string(sanctionRecord.SanctionCode),
|
||||
Scope: sanctionRecord.Scope.String(),
|
||||
ReasonCode: sanctionRecord.ReasonCode.String(),
|
||||
Actor: actorRefView(sanctionRecord.Actor),
|
||||
AppliedAt: sanctionRecord.AppliedAt.UTC(),
|
||||
ExpiresAt: cloneOptionalTime(sanctionRecord.ExpiresAt),
|
||||
})
|
||||
}
|
||||
for _, limitRecord := range aggregate.ActiveLimits {
|
||||
view.ActiveLimits = append(view.ActiveLimits, ActiveLimitView{
|
||||
LimitCode: string(limitRecord.LimitCode),
|
||||
Value: limitRecord.Value,
|
||||
ReasonCode: limitRecord.ReasonCode.String(),
|
||||
Actor: actorRefView(limitRecord.Actor),
|
||||
AppliedAt: limitRecord.AppliedAt.UTC(),
|
||||
ExpiresAt: cloneOptionalTime(limitRecord.ExpiresAt),
|
||||
})
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
type entitlementReader interface {
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
|
||||
}
|
||||
|
||||
// Loader materializes the shared current account aggregate for one user id.
|
||||
type Loader struct {
|
||||
accounts ports.UserAccountStore
|
||||
entitlements entitlementReader
|
||||
sanctions ports.SanctionStore
|
||||
limits ports.LimitStore
|
||||
clock ports.Clock
|
||||
}
|
||||
|
||||
// NewLoader constructs one shared account-aggregate loader.
|
||||
func NewLoader(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*Loader, error) {
|
||||
switch {
|
||||
case accounts == nil:
|
||||
return nil, fmt.Errorf("account view loader: user account store must not be nil")
|
||||
case entitlements == nil:
|
||||
return nil, fmt.Errorf("account view loader: entitlement reader must not be nil")
|
||||
case sanctions == nil:
|
||||
return nil, fmt.Errorf("account view loader: sanction store must not be nil")
|
||||
case limits == nil:
|
||||
return nil, fmt.Errorf("account view loader: limit store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("account view loader: clock must not be nil")
|
||||
default:
|
||||
return &Loader{
|
||||
accounts: accounts,
|
||||
entitlements: entitlements,
|
||||
sanctions: sanctions,
|
||||
limits: limits,
|
||||
clock: clock,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Load materializes the shared account aggregate identified by userID.
|
||||
func (loader *Loader) Load(ctx context.Context, userID common.UserID) (Aggregate, error) {
|
||||
if loader == nil {
|
||||
return Aggregate{}, shared.InternalError(fmt.Errorf("account view loader must not be nil"))
|
||||
}
|
||||
|
||||
accountRecord, err := loader.accounts.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Aggregate{}, shared.SubjectNotFound()
|
||||
default:
|
||||
return Aggregate{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
entitlementSnapshot, err := loader.entitlements.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Aggregate{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
|
||||
default:
|
||||
return Aggregate{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
sanctionRecords, err := loader.sanctions.ListByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return Aggregate{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
limitRecords, err := loader.limits.ListByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return Aggregate{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
now := loader.clock.Now().UTC()
|
||||
|
||||
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
|
||||
if err != nil {
|
||||
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
|
||||
}
|
||||
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
|
||||
if err != nil {
|
||||
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
|
||||
}
|
||||
|
||||
return Aggregate{
|
||||
AccountRecord: accountRecord,
|
||||
EntitlementSnapshot: entitlementSnapshot,
|
||||
ActiveSanctions: activeSanctions,
|
||||
ActiveLimits: activeLimits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func actorRefView(ref common.ActorRef) ActorRefView {
|
||||
return ActorRefView{
|
||||
Type: ref.Type.String(),
|
||||
ID: ref.ID.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneOptionalTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := value.UTC()
|
||||
return &cloned
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// Package adminusers implements the trusted administrative user-read surface
|
||||
// owned by User Service.
|
||||
package adminusers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/accountview"
|
||||
"galaxy/user/internal/service/shared"
|
||||
)
|
||||
|
||||
// LookupResult stores one exact trusted admin user lookup result.
|
||||
type LookupResult struct {
|
||||
// User stores the shared account aggregate of the resolved user.
|
||||
User accountview.AccountView `json:"user"`
|
||||
}
|
||||
|
||||
// GetUserByIDInput stores one exact trusted lookup by stable user identifier.
|
||||
type GetUserByIDInput struct {
|
||||
// UserID stores the stable regular-user identifier to resolve.
|
||||
UserID string
|
||||
}
|
||||
|
||||
// GetUserByEmailInput stores one exact trusted lookup by normalized e-mail.
|
||||
type GetUserByEmailInput struct {
|
||||
// Email stores the normalized login/contact e-mail to resolve.
|
||||
Email string
|
||||
}
|
||||
|
||||
// GetUserByRaceNameInput stores one exact trusted lookup by exact stored race
|
||||
// name.
|
||||
type GetUserByRaceNameInput struct {
|
||||
// RaceName stores the exact current race name to resolve.
|
||||
RaceName string
|
||||
}
|
||||
|
||||
// ListUsersInput stores one trusted administrative user-list request.
|
||||
type ListUsersInput struct {
|
||||
// PageSize stores the requested maximum number of returned users. The zero
|
||||
// value selects the frozen default page size.
|
||||
PageSize int
|
||||
|
||||
// PageToken stores the optional opaque continuation cursor.
|
||||
PageToken string
|
||||
|
||||
// PaidState stores the optional coarse free-versus-paid filter.
|
||||
PaidState string
|
||||
|
||||
// PaidExpiresBefore stores the optional strict finite paid-expiry upper
|
||||
// bound.
|
||||
PaidExpiresBefore *time.Time
|
||||
|
||||
// PaidExpiresAfter stores the optional strict finite paid-expiry lower
|
||||
// bound.
|
||||
PaidExpiresAfter *time.Time
|
||||
|
||||
// DeclaredCountry stores the optional current declared-country filter.
|
||||
DeclaredCountry string
|
||||
|
||||
// SanctionCode stores the optional active-sanction filter.
|
||||
SanctionCode string
|
||||
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode string
|
||||
|
||||
// CanLogin stores the optional derived login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
// CanCreatePrivateGame stores the optional derived private-game-create
|
||||
// eligibility filter.
|
||||
CanCreatePrivateGame *bool
|
||||
|
||||
// CanJoinGame stores the optional derived game-join eligibility filter.
|
||||
CanJoinGame *bool
|
||||
}
|
||||
|
||||
// ListUsersResult stores one trusted administrative page of user aggregates.
|
||||
type ListUsersResult struct {
|
||||
// Items stores the returned user aggregates in deterministic order.
|
||||
Items []accountview.AccountView `json:"items"`
|
||||
|
||||
// NextPageToken stores the optional continuation cursor for the next page.
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
type entitlementReader interface {
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
|
||||
}
|
||||
|
||||
type readSupport struct {
|
||||
accounts ports.UserAccountStore
|
||||
loader *accountview.Loader
|
||||
}
|
||||
|
||||
func newReadSupport(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (readSupport, error) {
|
||||
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return readSupport{}, fmt.Errorf("account view loader: %w", err)
|
||||
}
|
||||
|
||||
return readSupport{
|
||||
accounts: accounts,
|
||||
loader: loader,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ByIDGetter executes exact trusted lookups by stable user identifier.
|
||||
type ByIDGetter struct {
|
||||
support readSupport
|
||||
}
|
||||
|
||||
// NewByIDGetter constructs one exact admin lookup by user id.
|
||||
func NewByIDGetter(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*ByIDGetter, error) {
|
||||
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin users by-id getter: %w", err)
|
||||
}
|
||||
|
||||
return &ByIDGetter{support: support}, nil
|
||||
}
|
||||
|
||||
// Execute resolves one exact user by stable user identifier.
|
||||
func (service *ByIDGetter) Execute(ctx context.Context, input GetUserByIDInput) (LookupResult, error) {
|
||||
if ctx == nil {
|
||||
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
aggregate, err := service.support.loader.Load(ctx, userID)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
return LookupResult{User: aggregate.View()}, nil
|
||||
}
|
||||
|
||||
// ByEmailGetter executes exact trusted lookups by normalized e-mail.
|
||||
type ByEmailGetter struct {
|
||||
support readSupport
|
||||
}
|
||||
|
||||
// NewByEmailGetter constructs one exact admin lookup by normalized e-mail.
|
||||
func NewByEmailGetter(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*ByEmailGetter, error) {
|
||||
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin users by-email getter: %w", err)
|
||||
}
|
||||
|
||||
return &ByEmailGetter{support: support}, nil
|
||||
}
|
||||
|
||||
// Execute resolves one exact user by normalized e-mail.
|
||||
func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailInput) (LookupResult, error) {
|
||||
if ctx == nil {
|
||||
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
email, err := shared.ParseEmail(input.Email)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
record, err := service.support.accounts.GetByEmail(ctx, email)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return LookupResult{}, shared.SubjectNotFound()
|
||||
default:
|
||||
return LookupResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
aggregate, err := service.support.loader.Load(ctx, record.UserID)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
return LookupResult{User: aggregate.View()}, nil
|
||||
}
|
||||
|
||||
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
|
||||
type ByRaceNameGetter struct {
|
||||
support readSupport
|
||||
}
|
||||
|
||||
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
|
||||
// name.
|
||||
func NewByRaceNameGetter(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*ByRaceNameGetter, error) {
|
||||
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin users by-race-name getter: %w", err)
|
||||
}
|
||||
|
||||
return &ByRaceNameGetter{support: support}, nil
|
||||
}
|
||||
|
||||
// Execute resolves one exact user by exact stored race name.
|
||||
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
|
||||
if ctx == nil {
|
||||
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
raceName, err := shared.ParseRaceName(input.RaceName)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return LookupResult{}, shared.SubjectNotFound()
|
||||
default:
|
||||
return LookupResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
aggregate, err := service.support.loader.Load(ctx, record.UserID)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
return LookupResult{User: aggregate.View()}, nil
|
||||
}
|
||||
|
||||
// Lister executes the trusted administrative filtered user listing.
|
||||
type Lister struct {
|
||||
support readSupport
|
||||
listStore ports.UserListStore
|
||||
}
|
||||
|
||||
// NewLister constructs one trusted administrative filtered user lister.
|
||||
func NewLister(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
listStore ports.UserListStore,
|
||||
) (*Lister, error) {
|
||||
if listStore == nil {
|
||||
return nil, fmt.Errorf("admin users lister: user list store must not be nil")
|
||||
}
|
||||
|
||||
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin users lister: %w", err)
|
||||
}
|
||||
|
||||
return &Lister{
|
||||
support: support,
|
||||
listStore: listStore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute lists users in deterministic newest-first order and combines all
|
||||
// supplied filters with logical AND semantics.
|
||||
func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListUsersResult, error) {
|
||||
if ctx == nil {
|
||||
return ListUsersResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
if strings.TrimSpace(input.PageToken) != input.PageToken {
|
||||
return ListUsersResult{}, shared.InvalidRequest("page_token must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
pageSize, err := normalizePageSize(input.PageSize)
|
||||
if err != nil {
|
||||
return ListUsersResult{}, err
|
||||
}
|
||||
filters, err := parseListFilters(input)
|
||||
if err != nil {
|
||||
return ListUsersResult{}, err
|
||||
}
|
||||
|
||||
result := ListUsersResult{
|
||||
Items: make([]accountview.AccountView, 0, pageSize),
|
||||
}
|
||||
currentToken := input.PageToken
|
||||
|
||||
for len(result.Items) < pageSize {
|
||||
candidatePage, err := service.listStore.ListUserIDs(ctx, ports.ListUsersInput{
|
||||
PageSize: 1,
|
||||
PageToken: currentToken,
|
||||
Filters: filters,
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrInvalidPageToken):
|
||||
return ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
|
||||
default:
|
||||
return ListUsersResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if len(candidatePage.UserIDs) == 0 {
|
||||
result.NextPageToken = ""
|
||||
return result, nil
|
||||
}
|
||||
|
||||
nextToken := candidatePage.NextPageToken
|
||||
candidateID := candidatePage.UserIDs[0]
|
||||
|
||||
aggregate, err := service.support.loader.Load(ctx, candidateID)
|
||||
if err != nil {
|
||||
return ListUsersResult{}, err
|
||||
}
|
||||
if matchesFilters(aggregate, filters) {
|
||||
result.Items = append(result.Items, aggregate.View())
|
||||
result.NextPageToken = nextToken
|
||||
}
|
||||
|
||||
if nextToken == "" {
|
||||
result.NextPageToken = ""
|
||||
return result, nil
|
||||
}
|
||||
|
||||
currentToken = nextToken
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizePageSize(value int) (int, error) {
|
||||
switch {
|
||||
case value == 0:
|
||||
return ports.DefaultUserListPageSize, nil
|
||||
case value < 0:
|
||||
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
|
||||
case value > ports.MaxUserListPageSize:
|
||||
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
paidState, err := parsePaidState(input.PaidState)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
declaredCountry, err := parseCountryCode(input.DeclaredCountry)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
sanctionCode, err := parseSanctionCode(input.SanctionCode)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
limitCode, err := parseLimitCode(input.LimitCode)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
|
||||
filters := ports.UserListFilters{
|
||||
PaidState: paidState,
|
||||
PaidExpiresBefore: input.PaidExpiresBefore,
|
||||
PaidExpiresAfter: input.PaidExpiresAfter,
|
||||
DeclaredCountry: declaredCountry,
|
||||
SanctionCode: sanctionCode,
|
||||
LimitCode: limitCode,
|
||||
CanLogin: input.CanLogin,
|
||||
CanCreatePrivateGame: input.CanCreatePrivateGame,
|
||||
CanJoinGame: input.CanJoinGame,
|
||||
}
|
||||
if err := filters.Validate(); err != nil {
|
||||
return ports.UserListFilters{}, shared.InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func parsePaidState(value string) (entitlement.PaidState, error) {
|
||||
state := entitlement.PaidState(shared.NormalizeString(value))
|
||||
if !state.IsKnown() {
|
||||
return "", shared.InvalidRequest(fmt.Sprintf("paid_state %q is unsupported", state))
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func parseCountryCode(value string) (common.CountryCode, error) {
|
||||
code := common.CountryCode(shared.NormalizeString(value))
|
||||
if code.IsZero() {
|
||||
return "", nil
|
||||
}
|
||||
if err := code.Validate(); err != nil {
|
||||
return "", shared.InvalidRequest(fmt.Sprintf("declared_country: %s", err.Error()))
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func parseSanctionCode(value string) (policy.SanctionCode, error) {
|
||||
code := policy.SanctionCode(shared.NormalizeString(value))
|
||||
if code == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !code.IsKnown() {
|
||||
return "", shared.InvalidRequest(fmt.Sprintf("sanction_code %q is unsupported", code))
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func parseLimitCode(value string) (policy.LimitCode, error) {
|
||||
code := policy.LimitCode(shared.NormalizeString(value))
|
||||
if code == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !code.IsKnown() {
|
||||
return "", shared.InvalidRequest(fmt.Sprintf("limit_code %q is unsupported", code))
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilters) bool {
|
||||
switch filters.PaidState {
|
||||
case entitlement.PaidStateFree:
|
||||
if aggregate.EntitlementSnapshot.IsPaid {
|
||||
return false
|
||||
}
|
||||
case entitlement.PaidStatePaid:
|
||||
if !aggregate.EntitlementSnapshot.IsPaid {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if filters.PaidExpiresBefore != nil {
|
||||
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.Before(filters.PaidExpiresBefore.UTC()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if filters.PaidExpiresAfter != nil {
|
||||
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.After(filters.PaidExpiresAfter.UTC()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !filters.DeclaredCountry.IsZero() && aggregate.AccountRecord.DeclaredCountry != filters.DeclaredCountry {
|
||||
return false
|
||||
}
|
||||
if filters.SanctionCode != "" && !aggregate.HasActiveSanction(filters.SanctionCode) {
|
||||
return false
|
||||
}
|
||||
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
|
||||
return false
|
||||
}
|
||||
|
||||
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
|
||||
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
|
||||
return false
|
||||
}
|
||||
if filters.CanCreatePrivateGame != nil && canCreatePrivateGame != *filters.CanCreatePrivateGame {
|
||||
return false
|
||||
}
|
||||
if filters.CanJoinGame != nil && canJoinGame != *filters.CanJoinGame {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func deriveFilterEligibility(aggregate accountview.Aggregate) (bool, bool, bool) {
|
||||
canLogin := !aggregate.HasActiveSanction(policy.SanctionCodeLoginBlock)
|
||||
canCreatePrivateGame := canLogin &&
|
||||
aggregate.EntitlementSnapshot.IsPaid &&
|
||||
!aggregate.HasActiveSanction(policy.SanctionCodePrivateGameCreateBlock)
|
||||
canJoinGame := canLogin &&
|
||||
!aggregate.HasActiveSanction(policy.SanctionCodeGameJoinBlock)
|
||||
|
||||
return canLogin, canCreatePrivateGame, canJoinGame
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
package adminusers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestByIDGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewByIDGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeAdminSanctionStore{
|
||||
byUserID: map[common.UserID][]policy.SanctionRecord{
|
||||
common.UserID("user-123"): {
|
||||
validAdminActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
|
||||
expiredAdminSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
|
||||
},
|
||||
},
|
||||
},
|
||||
fakeAdminLimitStore{
|
||||
byUserID: map[common.UserID][]policy.LimitRecord{
|
||||
common.UserID("user-123"): {
|
||||
validAdminActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
},
|
||||
adminFixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserByIDInput{UserID: " user-123 "})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-123", result.User.UserID)
|
||||
require.Equal(t, "pilot@example.com", result.User.Email)
|
||||
require.Len(t, result.User.ActiveSanctions, 1)
|
||||
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.User.ActiveSanctions[0].SanctionCode)
|
||||
require.Len(t, result.User.ActiveLimits, 1)
|
||||
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.User.ActiveLimits[0].LimitCode)
|
||||
}
|
||||
|
||||
func TestByEmailGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewByEmailGetter(
|
||||
newFakeAdminAccountStore(),
|
||||
&fakeAdminEntitlementSnapshotStore{},
|
||||
fakeAdminSanctionStore{},
|
||||
fakeAdminLimitStore{},
|
||||
adminFixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), GetUserByEmailInput{Email: "missing@example.com"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewByRaceNameGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeAdminSanctionStore{},
|
||||
fakeAdminLimitStore{},
|
||||
adminFixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserByRaceNameInput{RaceName: " Pilot Nova "})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-123", result.User.UserID)
|
||||
require.Equal(t, "Pilot Nova", result.User.RaceName)
|
||||
}
|
||||
|
||||
func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
firstExpiry := now.Add(48 * time.Hour)
|
||||
secondExpiry := now.Add(72 * time.Hour)
|
||||
before := now.Add(96 * time.Hour)
|
||||
after := now.Add(24 * time.Hour)
|
||||
canLogin := false
|
||||
canCreatePrivateGame := false
|
||||
canJoinGame := false
|
||||
|
||||
accountStore := newFakeAdminAccountStore(
|
||||
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-300"): validAdminPaidSnapshot(common.UserID("user-300"), now, firstExpiry),
|
||||
common.UserID("user-200"): validAdminPaidSnapshot(common.UserID("user-200"), now, secondExpiry),
|
||||
common.UserID("user-100"): validAdminPaidSnapshot(common.UserID("user-100"), now, secondExpiry),
|
||||
},
|
||||
}
|
||||
sanctionStore := fakeAdminSanctionStore{
|
||||
byUserID: map[common.UserID][]policy.SanctionRecord{
|
||||
common.UserID("user-300"): {
|
||||
validAdminActiveSanction(common.UserID("user-300"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
|
||||
},
|
||||
common.UserID("user-200"): {
|
||||
validAdminActiveSanction(common.UserID("user-200"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
|
||||
},
|
||||
common.UserID("user-100"): {
|
||||
validAdminActiveSanction(common.UserID("user-100"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
}
|
||||
limitStore := fakeAdminLimitStore{
|
||||
byUserID: map[common.UserID][]policy.LimitRecord{
|
||||
common.UserID("user-300"): {
|
||||
validAdminActiveLimit(common.UserID("user-300"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
|
||||
},
|
||||
common.UserID("user-100"): {
|
||||
validAdminActiveLimit(common.UserID("user-100"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
}
|
||||
listStore := &fakeAdminListStore{
|
||||
pages: map[string]ports.ListUsersResult{
|
||||
"": {
|
||||
UserIDs: []common.UserID{common.UserID("user-300")},
|
||||
NextPageToken: "cursor-1",
|
||||
},
|
||||
"cursor-1": {
|
||||
UserIDs: []common.UserID{common.UserID("user-200")},
|
||||
NextPageToken: "cursor-2",
|
||||
},
|
||||
"cursor-2": {
|
||||
UserIDs: []common.UserID{common.UserID("user-100")},
|
||||
NextPageToken: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service, err := NewLister(accountStore, snapshotStore, sanctionStore, limitStore, adminFixedClock{now: now}, listStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), ListUsersInput{
|
||||
PageSize: 2,
|
||||
PaidState: "paid",
|
||||
PaidExpiresBefore: &before,
|
||||
PaidExpiresAfter: &after,
|
||||
DeclaredCountry: "DE",
|
||||
SanctionCode: "login_block",
|
||||
LimitCode: "max_owned_private_games",
|
||||
CanLogin: &canLogin,
|
||||
CanCreatePrivateGame: &canCreatePrivateGame,
|
||||
CanJoinGame: &canJoinGame,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 2)
|
||||
require.Equal(t, "user-300", result.Items[0].UserID)
|
||||
require.Equal(t, "user-100", result.Items[1].UserID)
|
||||
require.Equal(t, "", result.NextPageToken)
|
||||
require.Len(t, listStore.calls, 3)
|
||||
for _, call := range listStore.calls {
|
||||
require.Equal(t, 1, call.PageSize)
|
||||
require.Equal(t, entitlement.PaidStatePaid, call.Filters.PaidState)
|
||||
require.Equal(t, common.CountryCode("DE"), call.Filters.DeclaredCountry)
|
||||
require.Equal(t, policy.SanctionCodeLoginBlock, call.Filters.SanctionCode)
|
||||
require.Equal(t, policy.LimitCodeMaxOwnedPrivateGames, call.Filters.LimitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListerExecuteDefaultAndMaximumPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAdminAccountStore(
|
||||
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-300"): validAdminFreeSnapshot(common.UserID("user-300"), now),
|
||||
common.UserID("user-200"): validAdminFreeSnapshot(common.UserID("user-200"), now),
|
||||
common.UserID("user-100"): validAdminFreeSnapshot(common.UserID("user-100"), now),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("default page size", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listStore := &fakeAdminListStore{
|
||||
pages: map[string]ports.ListUsersResult{
|
||||
"": {
|
||||
UserIDs: []common.UserID{common.UserID("user-300")},
|
||||
NextPageToken: "cursor-1",
|
||||
},
|
||||
"cursor-1": {
|
||||
UserIDs: []common.UserID{common.UserID("user-200")},
|
||||
NextPageToken: "cursor-2",
|
||||
},
|
||||
"cursor-2": {
|
||||
UserIDs: []common.UserID{common.UserID("user-100")},
|
||||
NextPageToken: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), ListUsersInput{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 3)
|
||||
})
|
||||
|
||||
t.Run("maximum page size", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listStore := &fakeAdminListStore{
|
||||
pages: map[string]ports.ListUsersResult{
|
||||
"": {
|
||||
UserIDs: []common.UserID{common.UserID("user-300")},
|
||||
NextPageToken: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 1)
|
||||
})
|
||||
|
||||
t.Run("above maximum is rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, &fakeAdminListStore{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize + 1})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
require.Equal(t, "page_size must be between 1 and 200", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestListerExecuteInvalidPageTokenReturnsInvalidRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewLister(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeAdminSanctionStore{},
|
||||
fakeAdminLimitStore{},
|
||||
adminFixedClock{now: now},
|
||||
&fakeAdminListStore{err: fmt.Errorf("wrapped: %w", ports.ErrInvalidPageToken)},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), ListUsersInput{PageToken: "bad-token"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
require.Equal(t, "page_token is invalid or does not match current filters", err.Error())
|
||||
}
|
||||
|
||||
func TestListerExecuteRepairsExpiredPaidSnapshotBeforeFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
expiredAt := now.Add(-time.Hour)
|
||||
accountStore := newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now))
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): {
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: entitlement.PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: now.Add(-30 * 24 * time.Hour),
|
||||
EndsAt: adminTimePointer(expiredAt),
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
UpdatedAt: expiredAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
reader, err := entitlementsvc.NewReader(
|
||||
snapshotStore,
|
||||
&fakeAdminEntitlementLifecycleStore{snapshotStore: snapshotStore},
|
||||
adminFixedClock{now: now},
|
||||
adminReaderIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-repair-free-record")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
listStore := &fakeAdminListStore{
|
||||
pages: map[string]ports.ListUsersResult{
|
||||
"": {
|
||||
UserIDs: []common.UserID{common.UserID("user-123")},
|
||||
NextPageToken: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
service, err := NewLister(accountStore, reader, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), ListUsersInput{PaidState: "free"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 1)
|
||||
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
|
||||
require.False(t, result.Items[0].Entitlement.IsPaid)
|
||||
|
||||
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
|
||||
require.False(t, storedSnapshot.IsPaid)
|
||||
require.Equal(t, expiredAt, storedSnapshot.StartsAt)
|
||||
}
|
||||
|
||||
type adminFixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock adminFixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type adminReaderIDGenerator struct {
|
||||
recordID entitlement.EntitlementRecordID
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", errors.New("unexpected NewUserID call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", errors.New("unexpected NewInitialRaceName call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return generator.recordID, nil
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return "", errors.New("unexpected NewSanctionRecordID call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return "", errors.New("unexpected NewLimitRecordID call")
|
||||
}
|
||||
|
||||
type fakeAdminAccountStore struct {
|
||||
byUserID map[common.UserID]account.UserAccount
|
||||
byEmail map[common.Email]common.UserID
|
||||
byRaceName map[common.RaceName]common.UserID
|
||||
updateErr error
|
||||
renameErr error
|
||||
createErr error
|
||||
existsByID map[common.UserID]bool
|
||||
}
|
||||
|
||||
func newFakeAdminAccountStore(records ...account.UserAccount) *fakeAdminAccountStore {
|
||||
store := &fakeAdminAccountStore{
|
||||
byUserID: make(map[common.UserID]account.UserAccount, len(records)),
|
||||
byEmail: make(map[common.Email]common.UserID, len(records)),
|
||||
byRaceName: make(map[common.RaceName]common.UserID, len(records)),
|
||||
existsByID: make(map[common.UserID]bool, len(records)),
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
store.byUserID[record.UserID] = record
|
||||
store.byEmail[record.Email] = record.UserID
|
||||
store.byRaceName[record.RaceName] = record.UserID
|
||||
store.existsByID[record.UserID] = true
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return store.createErr
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
|
||||
record, ok := store.byUserID[userID]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
|
||||
userID, ok := store.byEmail[email]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return store.byUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
userID, ok := store.byRaceName[raceName]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return store.byUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
return store.existsByID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return store.renameErr
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) Update(context.Context, account.UserAccount) error {
|
||||
return store.updateErr
|
||||
}
|
||||
|
||||
type fakeAdminEntitlementSnapshotStore struct {
|
||||
byUserID map[common.UserID]entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
func (store *fakeAdminEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
||||
record, ok := store.byUserID[userID]
|
||||
if !ok {
|
||||
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
|
||||
if store.byUserID == nil {
|
||||
store.byUserID = make(map[common.UserID]entitlement.CurrentSnapshot)
|
||||
}
|
||||
store.byUserID[record.UserID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAdminEntitlementLifecycleStore struct {
|
||||
snapshotStore *fakeAdminEntitlementSnapshotStore
|
||||
}
|
||||
|
||||
func (store *fakeAdminEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
|
||||
return errors.New("unexpected Grant call")
|
||||
}
|
||||
|
||||
func (store *fakeAdminEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
|
||||
return errors.New("unexpected Extend call")
|
||||
}
|
||||
|
||||
func (store *fakeAdminEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
|
||||
return errors.New("unexpected Revoke call")
|
||||
}
|
||||
|
||||
func (store *fakeAdminEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
|
||||
return store.snapshotStore.Put(ctx, input.NewSnapshot)
|
||||
}
|
||||
|
||||
type fakeAdminSanctionStore struct {
|
||||
byUserID map[common.UserID][]policy.SanctionRecord
|
||||
}
|
||||
|
||||
func (store fakeAdminSanctionStore) Create(context.Context, policy.SanctionRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAdminSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
|
||||
return policy.SanctionRecord{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAdminSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
|
||||
return append([]policy.SanctionRecord(nil), store.byUserID[userID]...), nil
|
||||
}
|
||||
|
||||
func (store fakeAdminSanctionStore) Update(context.Context, policy.SanctionRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAdminLimitStore struct {
|
||||
byUserID map[common.UserID][]policy.LimitRecord
|
||||
}
|
||||
|
||||
func (store fakeAdminLimitStore) Create(context.Context, policy.LimitRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAdminLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
|
||||
return policy.LimitRecord{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAdminLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
|
||||
return append([]policy.LimitRecord(nil), store.byUserID[userID]...), nil
|
||||
}
|
||||
|
||||
func (store fakeAdminLimitStore) Update(context.Context, policy.LimitRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAdminListStore struct {
|
||||
pages map[string]ports.ListUsersResult
|
||||
err error
|
||||
calls []ports.ListUsersInput
|
||||
}
|
||||
|
||||
func (store *fakeAdminListStore) ListUserIDs(_ context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
|
||||
store.calls = append(store.calls, input)
|
||||
if store.err != nil {
|
||||
return ports.ListUsersResult{}, store.err
|
||||
}
|
||||
result, ok := store.pages[input.PageToken]
|
||||
if !ok {
|
||||
return ports.ListUsersResult{}, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validAdminUserAccount(userID string, email string, raceName string, now time.Time) account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID(userID),
|
||||
Email: common.Email(email),
|
||||
RaceName: common.RaceName(raceName),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func validAdminFreeSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: now,
|
||||
Source: common.Source("auth_registration"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func validAdminPaidSnapshot(userID common.UserID, now time.Time, endsAt time.Time) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: now.Add(-24 * time.Hour),
|
||||
EndsAt: adminTimePointer(endsAt),
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func validAdminActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
|
||||
return policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-" + string(code) + "-" + userID.String()),
|
||||
UserID: userID,
|
||||
SanctionCode: code,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: appliedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func expiredAdminSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
|
||||
record := validAdminActiveSanction(userID, code, appliedAt)
|
||||
record.ExpiresAt = adminTimePointer(appliedAt.Add(30 * time.Minute))
|
||||
return record
|
||||
}
|
||||
|
||||
func validAdminActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
|
||||
return policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-" + string(code) + "-" + userID.String()),
|
||||
UserID: userID,
|
||||
LimitCode: code,
|
||||
Value: value,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: appliedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func adminTimePointer(value time.Time) *time.Time {
|
||||
copied := value.UTC()
|
||||
return &copied
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// Package authdirectory implements the auth-facing user-resolution, ensure,
|
||||
// existence, and block use cases owned by the user service.
|
||||
package authdirectory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
const (
|
||||
initialEntitlementSource common.Source = "auth_registration"
|
||||
initialEntitlementReasonCode common.ReasonCode = "initial_free_entitlement"
|
||||
initialEntitlementActorType common.ActorType = "service"
|
||||
initialEntitlementActorID common.ActorID = "user-service"
|
||||
|
||||
ensureCreateRetryLimit = 8
|
||||
)
|
||||
|
||||
// ResolveByEmailInput stores one auth-facing resolve-by-email request.
|
||||
type ResolveByEmailInput struct {
|
||||
// Email stores the caller-supplied e-mail subject.
|
||||
Email string
|
||||
}
|
||||
|
||||
// ResolveByEmailResult stores one auth-facing resolve-by-email response.
|
||||
type ResolveByEmailResult struct {
|
||||
// Kind stores the coarse user-resolution outcome.
|
||||
Kind string
|
||||
|
||||
// UserID is present only when Kind is `existing`.
|
||||
UserID string
|
||||
|
||||
// BlockReasonCode is present only when Kind is `blocked`.
|
||||
BlockReasonCode string
|
||||
}
|
||||
|
||||
// Resolver executes the auth-facing resolve-by-email use case.
|
||||
type Resolver struct {
|
||||
store ports.AuthDirectoryStore
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// NewResolver returns one resolve-by-email use case instance.
|
||||
func NewResolver(store ports.AuthDirectoryStore) (*Resolver, error) {
|
||||
return NewResolverWithObservability(store, nil, nil)
|
||||
}
|
||||
|
||||
// NewResolverWithObservability returns one resolve-by-email use case instance
|
||||
// with optional structured logging and metrics hooks.
|
||||
func NewResolverWithObservability(
|
||||
store ports.AuthDirectoryStore,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Resolver, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("authdirectory resolver: auth directory store must not be nil")
|
||||
}
|
||||
|
||||
return &Resolver{
|
||||
store: store,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute resolves one e-mail subject without creating any account.
|
||||
func (service *Resolver) Execute(ctx context.Context, input ResolveByEmailInput) (result ResolveByEmailResult, err error) {
|
||||
outcome := "failed"
|
||||
defer func() {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordAuthResolutionOutcome(ctx, "resolve_by_email", outcome)
|
||||
}
|
||||
if err != nil {
|
||||
shared.LogServiceOutcome(service.logger, ctx, "auth resolution failed", err,
|
||||
"use_case", "resolve_by_email",
|
||||
"outcome", outcome,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
return ResolveByEmailResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
email, err := shared.ParseEmail(input.Email)
|
||||
if err != nil {
|
||||
return ResolveByEmailResult{}, err
|
||||
}
|
||||
|
||||
resolution, err := service.store.ResolveByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return ResolveByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := resolution.Validate(); err != nil {
|
||||
return ResolveByEmailResult{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
result = ResolveByEmailResult{
|
||||
Kind: string(resolution.Kind),
|
||||
}
|
||||
if !resolution.UserID.IsZero() {
|
||||
result.UserID = resolution.UserID.String()
|
||||
}
|
||||
if !resolution.BlockReasonCode.IsZero() {
|
||||
result.BlockReasonCode = resolution.BlockReasonCode.String()
|
||||
}
|
||||
outcome = result.Kind
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RegistrationContext stores the create-only auth-facing initialization
|
||||
// context forwarded by authsession.
|
||||
type RegistrationContext struct {
|
||||
// PreferredLanguage stores the initial preferred language.
|
||||
PreferredLanguage string
|
||||
|
||||
// TimeZone stores the initial declared time-zone name.
|
||||
TimeZone string
|
||||
}
|
||||
|
||||
// EnsureByEmailInput stores one auth-facing ensure-by-email request.
|
||||
type EnsureByEmailInput struct {
|
||||
// Email stores the caller-supplied e-mail subject.
|
||||
Email string
|
||||
|
||||
// RegistrationContext stores the required create-only registration context.
|
||||
RegistrationContext *RegistrationContext
|
||||
}
|
||||
|
||||
// EnsureByEmailResult stores one auth-facing ensure-by-email response.
|
||||
type EnsureByEmailResult struct {
|
||||
// Outcome stores the coarse ensure outcome.
|
||||
Outcome string
|
||||
|
||||
// UserID is present only for `existing` and `created`.
|
||||
UserID string
|
||||
|
||||
// BlockReasonCode is present only for `blocked`.
|
||||
BlockReasonCode string
|
||||
}
|
||||
|
||||
// Ensurer executes the auth-facing ensure-by-email use case.
|
||||
type Ensurer struct {
|
||||
store ports.AuthDirectoryStore
|
||||
clock ports.Clock
|
||||
idGenerator ports.IDGenerator
|
||||
policy ports.RaceNamePolicy
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
profilePublisher ports.ProfileChangedPublisher
|
||||
settingsPublisher ports.SettingsChangedPublisher
|
||||
entitlementPublisher ports.EntitlementChangedPublisher
|
||||
}
|
||||
|
||||
// NewEnsurer returns one ensure-by-email use case instance.
|
||||
func NewEnsurer(
|
||||
store ports.AuthDirectoryStore,
|
||||
clock ports.Clock,
|
||||
idGenerator ports.IDGenerator,
|
||||
policy ports.RaceNamePolicy,
|
||||
) (*Ensurer, error) {
|
||||
return NewEnsurerWithObservability(store, clock, idGenerator, policy, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewEnsurerWithObservability returns one ensure-by-email use case instance
|
||||
// with optional structured logging, metrics, and post-commit event
|
||||
// publication hooks.
|
||||
func NewEnsurerWithObservability(
|
||||
store ports.AuthDirectoryStore,
|
||||
clock ports.Clock,
|
||||
idGenerator ports.IDGenerator,
|
||||
policy ports.RaceNamePolicy,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
profilePublisher ports.ProfileChangedPublisher,
|
||||
settingsPublisher ports.SettingsChangedPublisher,
|
||||
entitlementPublisher ports.EntitlementChangedPublisher,
|
||||
) (*Ensurer, error) {
|
||||
switch {
|
||||
case store == nil:
|
||||
return nil, fmt.Errorf("authdirectory ensurer: auth directory store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("authdirectory ensurer: clock must not be nil")
|
||||
case idGenerator == nil:
|
||||
return nil, fmt.Errorf("authdirectory ensurer: id generator must not be nil")
|
||||
case policy == nil:
|
||||
return nil, fmt.Errorf("authdirectory ensurer: race-name policy must not be nil")
|
||||
default:
|
||||
return &Ensurer{
|
||||
store: store,
|
||||
clock: clock,
|
||||
idGenerator: idGenerator,
|
||||
policy: policy,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
profilePublisher: profilePublisher,
|
||||
settingsPublisher: settingsPublisher,
|
||||
entitlementPublisher: entitlementPublisher,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute ensures that one e-mail subject maps to an existing user, a newly
|
||||
// created user, or a blocked outcome.
|
||||
func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (result EnsureByEmailResult, err error) {
|
||||
outcome := "failed"
|
||||
userIDString := ""
|
||||
defer func() {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordUserCreationOutcome(ctx, outcome)
|
||||
}
|
||||
shared.LogServiceOutcome(service.logger, ctx, "ensure by email completed", err,
|
||||
"use_case", "ensure_by_email",
|
||||
"outcome", outcome,
|
||||
"user_id", userIDString,
|
||||
"source", initialEntitlementSource.String(),
|
||||
)
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
return EnsureByEmailResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
email, err := shared.ParseEmail(input.Email)
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, err
|
||||
}
|
||||
if input.RegistrationContext == nil {
|
||||
return EnsureByEmailResult{}, shared.InvalidRequest("registration_context must be present")
|
||||
}
|
||||
|
||||
preferredLanguage, err := shared.ParseRegistrationPreferredLanguage(input.RegistrationContext.PreferredLanguage)
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, err
|
||||
}
|
||||
timeZone, err := shared.ParseRegistrationTimeZoneName(input.RegistrationContext.TimeZone)
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, err
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC()
|
||||
|
||||
for attempt := 0; attempt < ensureCreateRetryLimit; attempt++ {
|
||||
userID, err := service.idGenerator.NewUserID()
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
raceName, err := service.idGenerator.NewInitialRaceName()
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
accountRecord := account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RaceName: raceName,
|
||||
PreferredLanguage: preferredLanguage,
|
||||
TimeZone: timeZone,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
entitlementSnapshot := entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: now,
|
||||
Source: initialEntitlementSource,
|
||||
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
|
||||
ReasonCode: initialEntitlementReasonCode,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
entitlementRecordID, err := service.idGenerator.NewEntitlementRecordID()
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
entitlementRecord := entitlement.PeriodRecord{
|
||||
RecordID: entitlementRecordID,
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: initialEntitlementSource,
|
||||
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
|
||||
ReasonCode: initialEntitlementReasonCode,
|
||||
StartsAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
ensureResult, err := service.store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
|
||||
Email: email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: entitlementRecord,
|
||||
Reservation: reservation,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
|
||||
service.telemetry.RecordRaceNameReservationConflict(ctx, "ensure_by_email")
|
||||
}
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := ensureResult.Validate(); err != nil {
|
||||
return EnsureByEmailResult{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
result = EnsureByEmailResult{
|
||||
Outcome: string(ensureResult.Outcome),
|
||||
}
|
||||
if !ensureResult.UserID.IsZero() {
|
||||
result.UserID = ensureResult.UserID.String()
|
||||
userIDString = result.UserID
|
||||
}
|
||||
if !ensureResult.BlockReasonCode.IsZero() {
|
||||
result.BlockReasonCode = ensureResult.BlockReasonCode.String()
|
||||
}
|
||||
outcome = result.Outcome
|
||||
|
||||
if result.Outcome == string(ports.EnsureByEmailOutcomeCreated) {
|
||||
service.publishInitializedEvents(ctx, accountRecord, entitlementSnapshot)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(fmt.Errorf("ensure-by-email conflict retry limit exceeded"))
|
||||
}
|
||||
|
||||
func (service *Ensurer) publishInitializedEvents(
|
||||
ctx context.Context,
|
||||
accountRecord account.UserAccount,
|
||||
entitlementSnapshot entitlement.CurrentSnapshot,
|
||||
) {
|
||||
occurredAt := accountRecord.UpdatedAt.UTC()
|
||||
|
||||
service.publishProfileChanged(ctx, ports.ProfileChangedEvent{
|
||||
UserID: accountRecord.UserID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: initialEntitlementSource,
|
||||
Operation: ports.ProfileChangedOperationInitialized,
|
||||
RaceName: accountRecord.RaceName,
|
||||
})
|
||||
service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{
|
||||
UserID: accountRecord.UserID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: initialEntitlementSource,
|
||||
Operation: ports.SettingsChangedOperationInitialized,
|
||||
PreferredLanguage: accountRecord.PreferredLanguage,
|
||||
TimeZone: accountRecord.TimeZone,
|
||||
})
|
||||
service.publishEntitlementChanged(ctx, ports.EntitlementChangedEvent{
|
||||
UserID: entitlementSnapshot.UserID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: initialEntitlementSource,
|
||||
Operation: ports.EntitlementChangedOperationInitialized,
|
||||
PlanCode: entitlementSnapshot.PlanCode,
|
||||
IsPaid: entitlementSnapshot.IsPaid,
|
||||
StartsAt: entitlementSnapshot.StartsAt,
|
||||
EndsAt: entitlementSnapshot.EndsAt,
|
||||
ReasonCode: entitlementSnapshot.ReasonCode,
|
||||
Actor: entitlementSnapshot.Actor,
|
||||
UpdatedAt: entitlementSnapshot.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Ensurer) publishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) {
|
||||
if service.profilePublisher == nil {
|
||||
return
|
||||
}
|
||||
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
|
||||
}
|
||||
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
|
||||
"use_case", "ensure_by_email",
|
||||
"user_id", event.UserID.String(),
|
||||
"source", event.Source.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Ensurer) publishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) {
|
||||
if service.settingsPublisher == nil {
|
||||
return
|
||||
}
|
||||
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
|
||||
}
|
||||
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
|
||||
"use_case", "ensure_by_email",
|
||||
"user_id", event.UserID.String(),
|
||||
"source", event.Source.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Ensurer) publishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) {
|
||||
if service.entitlementPublisher == nil {
|
||||
return
|
||||
}
|
||||
if err := service.entitlementPublisher.PublishEntitlementChanged(ctx, event); err != nil {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordEventPublicationFailure(ctx, ports.EntitlementChangedEventType)
|
||||
}
|
||||
shared.LogEventPublicationFailure(service.logger, ctx, ports.EntitlementChangedEventType, err,
|
||||
"use_case", "ensure_by_email",
|
||||
"user_id", event.UserID.String(),
|
||||
"source", event.Source.String(),
|
||||
"reason_code", event.ReasonCode.String(),
|
||||
"actor_type", event.Actor.Type.String(),
|
||||
"actor_id", event.Actor.ID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ExistsByUserIDInput stores one auth-facing existence check request.
|
||||
type ExistsByUserIDInput struct {
|
||||
// UserID stores the caller-supplied stable user identifier.
|
||||
UserID string
|
||||
}
|
||||
|
||||
// ExistsByUserIDResult stores one auth-facing existence check response.
|
||||
type ExistsByUserIDResult struct {
|
||||
// Exists reports whether the supplied user identifier currently exists.
|
||||
Exists bool
|
||||
}
|
||||
|
||||
// ExistenceChecker executes the auth-facing exists-by-user-id use case.
|
||||
type ExistenceChecker struct {
|
||||
store ports.AuthDirectoryStore
|
||||
}
|
||||
|
||||
// NewExistenceChecker returns one exists-by-user-id use case instance.
|
||||
func NewExistenceChecker(store ports.AuthDirectoryStore) (*ExistenceChecker, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("authdirectory existence checker: auth directory store must not be nil")
|
||||
}
|
||||
|
||||
return &ExistenceChecker{store: store}, nil
|
||||
}
|
||||
|
||||
// Execute reports whether one stable user identifier exists.
|
||||
func (service *ExistenceChecker) Execute(ctx context.Context, input ExistsByUserIDInput) (ExistsByUserIDResult, error) {
|
||||
if ctx == nil {
|
||||
return ExistsByUserIDResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return ExistsByUserIDResult{}, err
|
||||
}
|
||||
|
||||
exists, err := service.store.ExistsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return ExistsByUserIDResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
return ExistsByUserIDResult{Exists: exists}, nil
|
||||
}
|
||||
|
||||
// BlockByUserIDInput stores one auth-facing block-by-user-id request.
|
||||
type BlockByUserIDInput struct {
|
||||
// UserID stores the stable account identifier that must be blocked.
|
||||
UserID string
|
||||
|
||||
// ReasonCode stores the machine-readable block reason.
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
// BlockByEmailInput stores one auth-facing block-by-email request.
|
||||
type BlockByEmailInput struct {
|
||||
// Email stores the exact normalized e-mail subject that must be blocked.
|
||||
Email string
|
||||
|
||||
// ReasonCode stores the machine-readable block reason.
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
// BlockResult stores one auth-facing block response.
|
||||
type BlockResult struct {
|
||||
// Outcome reports whether the current call created a new block.
|
||||
Outcome string
|
||||
|
||||
// UserID stores the resolved account when the blocked subject belongs to an
|
||||
// existing user.
|
||||
UserID string
|
||||
}
|
||||
|
||||
// BlockByUserIDService executes the auth-facing block-by-user-id use case.
|
||||
type BlockByUserIDService struct {
|
||||
store ports.AuthDirectoryStore
|
||||
clock ports.Clock
|
||||
}
|
||||
|
||||
// NewBlockByUserIDService returns one block-by-user-id use case instance.
|
||||
func NewBlockByUserIDService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByUserIDService, error) {
|
||||
switch {
|
||||
case store == nil:
|
||||
return nil, fmt.Errorf("authdirectory block-by-user-id service: auth directory store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("authdirectory block-by-user-id service: clock must not be nil")
|
||||
default:
|
||||
return &BlockByUserIDService{store: store, clock: clock}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute blocks one account addressed by stable user identifier.
|
||||
func (service *BlockByUserIDService) Execute(ctx context.Context, input BlockByUserIDInput) (BlockResult, error) {
|
||||
if ctx == nil {
|
||||
return BlockResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return BlockResult{}, err
|
||||
}
|
||||
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
|
||||
if err != nil {
|
||||
return BlockResult{}, err
|
||||
}
|
||||
|
||||
result, err := service.store.BlockByUserID(ctx, ports.BlockByUserIDInput{
|
||||
UserID: userID,
|
||||
ReasonCode: reasonCode,
|
||||
BlockedAt: service.clock.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return BlockResult{}, shared.SubjectNotFound()
|
||||
default:
|
||||
return BlockResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return BlockResult{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
response := BlockResult{Outcome: string(result.Outcome)}
|
||||
if !result.UserID.IsZero() {
|
||||
response.UserID = result.UserID.String()
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// BlockByEmailService executes the auth-facing block-by-email use case.
|
||||
type BlockByEmailService struct {
|
||||
store ports.AuthDirectoryStore
|
||||
clock ports.Clock
|
||||
}
|
||||
|
||||
// NewBlockByEmailService returns one block-by-email use case instance.
|
||||
func NewBlockByEmailService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByEmailService, error) {
|
||||
switch {
|
||||
case store == nil:
|
||||
return nil, fmt.Errorf("authdirectory block-by-email service: auth directory store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("authdirectory block-by-email service: clock must not be nil")
|
||||
default:
|
||||
return &BlockByEmailService{store: store, clock: clock}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute blocks one exact normalized e-mail subject.
|
||||
func (service *BlockByEmailService) Execute(ctx context.Context, input BlockByEmailInput) (BlockResult, error) {
|
||||
if ctx == nil {
|
||||
return BlockResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
email, err := shared.ParseEmail(input.Email)
|
||||
if err != nil {
|
||||
return BlockResult{}, err
|
||||
}
|
||||
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
|
||||
if err != nil {
|
||||
return BlockResult{}, err
|
||||
}
|
||||
|
||||
result, err := service.store.BlockByEmail(ctx, ports.BlockByEmailInput{
|
||||
Email: email,
|
||||
ReasonCode: reasonCode,
|
||||
BlockedAt: service.clock.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return BlockResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return BlockResult{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
response := BlockResult{Outcome: string(result.Outcome)}
|
||||
if !result.UserID.IsZero() {
|
||||
response.UserID = result.UserID.String()
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
package authdirectory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
func TestResolverExecute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store stubAuthDirectoryStore
|
||||
wantKind string
|
||||
wantUserID string
|
||||
wantBlock string
|
||||
}{
|
||||
{
|
||||
name: "existing",
|
||||
store: stubAuthDirectoryStore{
|
||||
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
|
||||
require.Equal(t, common.Email("pilot@example.com"), email)
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindExisting,
|
||||
UserID: common.UserID("user-123"),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
wantKind: "existing",
|
||||
wantUserID: "user-123",
|
||||
},
|
||||
{
|
||||
name: "creatable",
|
||||
store: stubAuthDirectoryStore{
|
||||
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
|
||||
require.Equal(t, common.Email("pilot@example.com"), email)
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindCreatable,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
wantKind: "creatable",
|
||||
},
|
||||
{
|
||||
name: "blocked",
|
||||
store: stubAuthDirectoryStore{
|
||||
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
|
||||
require.Equal(t, common.Email("pilot@example.com"), email)
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindBlocked,
|
||||
BlockReasonCode: common.ReasonCode("policy_blocked"),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
wantKind: "blocked",
|
||||
wantBlock: "policy_blocked",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver, err := NewResolver(tt.store)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := resolver.Execute(context.Background(), ResolveByEmailInput{
|
||||
Email: " pilot@example.com ",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantKind, result.Kind)
|
||||
require.Equal(t, tt.wantUserID, result.UserID)
|
||||
require.Equal(t, tt.wantBlock, result.BlockReasonCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
require.Equal(t, common.Email("created@example.com"), input.Email)
|
||||
require.Equal(t, common.UserID("user-created"), input.Account.UserID)
|
||||
require.Equal(t, common.RaceName("player-test123"), input.Account.RaceName)
|
||||
require.Equal(t, common.LanguageTag("en-US"), input.Account.PreferredLanguage)
|
||||
require.Equal(t, common.TimeZoneName("Europe/Kaliningrad"), input.Account.TimeZone)
|
||||
require.Equal(t, input.Account.UserID, input.Reservation.UserID)
|
||||
require.Equal(t, input.Account.RaceName, input.Reservation.RaceName)
|
||||
require.Equal(t, accountTestCanonicalKey(input.Account.RaceName), input.Reservation.CanonicalKey)
|
||||
require.Equal(t, entitlement.PlanCodeFree, input.Entitlement.PlanCode)
|
||||
require.False(t, input.Entitlement.IsPaid)
|
||||
require.Equal(t, input.Account.UserID, input.Entitlement.UserID)
|
||||
require.Equal(t, entitlement.EntitlementRecordID("entitlement-created"), input.EntitlementRecord.RecordID)
|
||||
require.Equal(t, input.Account.UserID, input.EntitlementRecord.UserID)
|
||||
require.Equal(t, input.Entitlement.PlanCode, input.EntitlementRecord.PlanCode)
|
||||
require.Equal(t, input.Entitlement.StartsAt, input.EntitlementRecord.StartsAt)
|
||||
require.Equal(t, input.Entitlement.Source, input.EntitlementRecord.Source)
|
||||
require.Equal(t, input.Entitlement.Actor, input.EntitlementRecord.Actor)
|
||||
require.Equal(t, input.Entitlement.ReasonCode, input.EntitlementRecord.ReasonCode)
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeCreated,
|
||||
UserID: input.Account.UserID,
|
||||
}, nil
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
Email: "created@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en-us",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "created", result.Outcome)
|
||||
require.Equal(t, "user-created", result.UserID)
|
||||
}
|
||||
|
||||
func TestEnsurerExecuteRejectsInvalidRegistrationContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input EnsureByEmailInput
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid preferred language",
|
||||
input: EnsureByEmailInput{
|
||||
Email: "pilot@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "bad@@tag",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
},
|
||||
},
|
||||
wantErr: "registration_context.preferred_language must be a valid BCP 47 language tag",
|
||||
},
|
||||
{
|
||||
name: "invalid time zone",
|
||||
input: EnsureByEmailInput{
|
||||
Email: "pilot@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "Mars/Olympus",
|
||||
},
|
||||
},
|
||||
wantErr: "registration_context.time_zone must be a valid IANA time zone name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ensurer, err := NewEnsurer(stubAuthDirectoryStore{}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ensurer.Execute(context.Background(), tt.input)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
require.Equal(t, tt.wantErr, err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurerExecuteRetriesConflicts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
attempt := 0
|
||||
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
attempt++
|
||||
if attempt == 1 {
|
||||
return ports.EnsureByEmailResult{}, ports.ErrConflict
|
||||
}
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeCreated,
|
||||
UserID: input.Account.UserID,
|
||||
}, nil
|
||||
},
|
||||
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, &sequenceIDGenerator{
|
||||
userIDs: []common.UserID{"user-first", "user-second"},
|
||||
raceNames: []common.RaceName{"player-first", "player-second"},
|
||||
entitlementRecordIDs: []entitlement.EntitlementRecordID{"entitlement-first", "entitlement-second"},
|
||||
}, stubRaceNamePolicy{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
Email: "retry@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, attempt)
|
||||
require.Equal(t, "user-second", result.UserID)
|
||||
}
|
||||
|
||||
func TestEnsurerExecuteReturnsExistingAndBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store stubAuthDirectoryStore
|
||||
want EnsureByEmailResult
|
||||
}{
|
||||
{
|
||||
name: "existing",
|
||||
store: stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
require.Equal(t, common.Email("pilot@example.com"), input.Email)
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeExisting,
|
||||
UserID: common.UserID("user-existing"),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
want: EnsureByEmailResult{
|
||||
Outcome: "existing",
|
||||
UserID: "user-existing",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocked",
|
||||
store: stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
require.Equal(t, common.Email("pilot@example.com"), input.Email)
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeBlocked,
|
||||
BlockReasonCode: common.ReasonCode("policy_blocked"),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
want: EnsureByEmailResult{
|
||||
Outcome: "blocked",
|
||||
BlockReasonCode: "policy_blocked",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ensurer, err := NewEnsurer(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
Email: "pilot@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurerExecuteCreatedPublishesInitializedEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
publisher := &recordingAuthDomainEventPublisher{}
|
||||
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
|
||||
|
||||
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeCreated,
|
||||
UserID: input.Account.UserID,
|
||||
}, nil
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
Email: "created@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en-us",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "created", result.Outcome)
|
||||
|
||||
require.Len(t, publisher.profileEvents, 1)
|
||||
require.Equal(t, ports.ProfileChangedOperationInitialized, publisher.profileEvents[0].Operation)
|
||||
require.Equal(t, common.Source("auth_registration"), publisher.profileEvents[0].Source)
|
||||
require.Len(t, publisher.settingsEvents, 1)
|
||||
require.Equal(t, ports.SettingsChangedOperationInitialized, publisher.settingsEvents[0].Operation)
|
||||
require.Len(t, publisher.entitlementEvents, 1)
|
||||
require.Equal(t, ports.EntitlementChangedOperationInitialized, publisher.entitlementEvents[0].Operation)
|
||||
|
||||
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
|
||||
"outcome": "created",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func TestEnsurerExecuteExistingBlockedAndFailedDoNotPublishEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store stubAuthDirectoryStore
|
||||
input EnsureByEmailInput
|
||||
wantMetric string
|
||||
wantErrCode string
|
||||
wantProfileLen int
|
||||
}{
|
||||
{
|
||||
name: "existing",
|
||||
store: stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeExisting,
|
||||
UserID: common.UserID("user-existing"),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
input: EnsureByEmailInput{
|
||||
Email: "pilot@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
},
|
||||
},
|
||||
wantMetric: "existing",
|
||||
},
|
||||
{
|
||||
name: "blocked",
|
||||
store: stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeBlocked,
|
||||
BlockReasonCode: common.ReasonCode("policy_blocked"),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
input: EnsureByEmailInput{
|
||||
Email: "pilot@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
},
|
||||
},
|
||||
wantMetric: "blocked",
|
||||
},
|
||||
{
|
||||
name: "failed",
|
||||
store: stubAuthDirectoryStore{},
|
||||
input: EnsureByEmailInput{
|
||||
Email: "pilot@example.com",
|
||||
},
|
||||
wantMetric: "failed",
|
||||
wantErrCode: shared.ErrorCodeInvalidRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
publisher := &recordingAuthDomainEventPublisher{}
|
||||
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
|
||||
ensurer, err := NewEnsurerWithObservability(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ensurer.Execute(context.Background(), tt.input)
|
||||
if tt.wantErrCode != "" {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tt.wantErrCode, shared.CodeOf(err))
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Empty(t, publisher.profileEvents)
|
||||
require.Empty(t, publisher.settingsEvents)
|
||||
require.Empty(t, publisher.entitlementEvents)
|
||||
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
|
||||
"outcome": tt.wantMetric,
|
||||
}, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurerExecutePublishFailureDoesNotRollbackCreatedUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
publisher := &recordingAuthDomainEventPublisher{err: errors.New("publisher unavailable")}
|
||||
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
|
||||
|
||||
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
|
||||
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
return ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeCreated,
|
||||
UserID: input.Account.UserID,
|
||||
}, nil
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
Email: "created@example.com",
|
||||
RegistrationContext: &RegistrationContext{
|
||||
PreferredLanguage: "en-us",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "created", result.Outcome)
|
||||
require.Len(t, publisher.profileEvents, 1)
|
||||
require.Len(t, publisher.settingsEvents, 1)
|
||||
require.Len(t, publisher.entitlementEvents, 1)
|
||||
|
||||
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
|
||||
"event_type": ports.ProfileChangedEventType,
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
|
||||
"event_type": ports.SettingsChangedEventType,
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
|
||||
"event_type": ports.EntitlementChangedEventType,
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func TestBlockByUserIDServiceMapsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewBlockByUserIDService(stubAuthDirectoryStore{
|
||||
blockByUserID: func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error) {
|
||||
return ports.BlockResult{}, ports.ErrNotFound
|
||||
},
|
||||
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), BlockByUserIDInput{
|
||||
UserID: "user-missing",
|
||||
ReasonCode: "policy_blocked",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
type stubAuthDirectoryStore struct {
|
||||
resolveByEmail func(context.Context, common.Email) (ports.ResolveByEmailResult, error)
|
||||
ensureByEmail func(context.Context, ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error)
|
||||
existsByUserID func(context.Context, common.UserID) (bool, error)
|
||||
blockByUserID func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error)
|
||||
blockByEmail func(context.Context, ports.BlockByEmailInput) (ports.BlockResult, error)
|
||||
}
|
||||
|
||||
func (store stubAuthDirectoryStore) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
|
||||
if store.resolveByEmail == nil {
|
||||
return ports.ResolveByEmailResult{}, errors.New("unexpected ResolveByEmail call")
|
||||
}
|
||||
return store.resolveByEmail(ctx, email)
|
||||
}
|
||||
|
||||
func (store stubAuthDirectoryStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
||||
if store.existsByUserID == nil {
|
||||
return false, errors.New("unexpected ExistsByUserID call")
|
||||
}
|
||||
return store.existsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (store stubAuthDirectoryStore) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
if store.ensureByEmail == nil {
|
||||
return ports.EnsureByEmailResult{}, errors.New("unexpected EnsureByEmail call")
|
||||
}
|
||||
return store.ensureByEmail(ctx, input)
|
||||
}
|
||||
|
||||
func (store stubAuthDirectoryStore) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) {
|
||||
if store.blockByUserID == nil {
|
||||
return ports.BlockResult{}, errors.New("unexpected BlockByUserID call")
|
||||
}
|
||||
return store.blockByUserID(ctx, input)
|
||||
}
|
||||
|
||||
func (store stubAuthDirectoryStore) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) {
|
||||
if store.blockByEmail == nil {
|
||||
return ports.BlockResult{}, errors.New("unexpected BlockByEmail call")
|
||||
}
|
||||
return store.blockByEmail(ctx, input)
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type fixedIDGenerator struct {
|
||||
userID common.UserID
|
||||
raceName common.RaceName
|
||||
entitlementRecordID entitlement.EntitlementRecordID
|
||||
sanctionRecordID policy.SanctionRecordID
|
||||
limitRecordID policy.LimitRecordID
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return generator.userID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return generator.raceName, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return generator.entitlementRecordID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return generator.sanctionRecordID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return generator.limitRecordID, nil
|
||||
}
|
||||
|
||||
type sequenceIDGenerator struct {
|
||||
userIDs []common.UserID
|
||||
raceNames []common.RaceName
|
||||
entitlementRecordIDs []entitlement.EntitlementRecordID
|
||||
sanctionRecordIDs []policy.SanctionRecordID
|
||||
limitRecordIDs []policy.LimitRecordID
|
||||
}
|
||||
|
||||
func (generator *sequenceIDGenerator) NewUserID() (common.UserID, error) {
|
||||
value := generator.userIDs[0]
|
||||
generator.userIDs = generator.userIDs[1:]
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (generator *sequenceIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
value := generator.raceNames[0]
|
||||
generator.raceNames = generator.raceNames[1:]
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (generator *sequenceIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
value := generator.entitlementRecordIDs[0]
|
||||
generator.entitlementRecordIDs = generator.entitlementRecordIDs[1:]
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (generator *sequenceIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
value := generator.sanctionRecordIDs[0]
|
||||
generator.sanctionRecordIDs = generator.sanctionRecordIDs[1:]
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (generator *sequenceIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
value := generator.limitRecordIDs[0]
|
||||
generator.limitRecordIDs = generator.limitRecordIDs[1:]
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type stubRaceNamePolicy struct{}
|
||||
|
||||
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
return accountTestCanonicalKey(raceName), nil
|
||||
}
|
||||
|
||||
func accountTestCanonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
||||
return account.RaceNameCanonicalKey("key:" + raceName.String())
|
||||
}
|
||||
|
||||
type recordingAuthDomainEventPublisher struct {
|
||||
err error
|
||||
profileEvents []ports.ProfileChangedEvent
|
||||
settingsEvents []ports.SettingsChangedEvent
|
||||
entitlementEvents []ports.EntitlementChangedEvent
|
||||
}
|
||||
|
||||
func (publisher *recordingAuthDomainEventPublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.profileEvents = append(publisher.profileEvents, event)
|
||||
return publisher.err
|
||||
}
|
||||
|
||||
func (publisher *recordingAuthDomainEventPublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.settingsEvents = append(publisher.settingsEvents, event)
|
||||
return publisher.err
|
||||
}
|
||||
|
||||
func (publisher *recordingAuthDomainEventPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.entitlementEvents = append(publisher.entitlementEvents, event)
|
||||
return publisher.err
|
||||
}
|
||||
|
||||
func newObservedAuthTelemetryRuntime(t *testing.T) (*telemetry.Runtime, *sdkmetric.ManualReader) {
|
||||
t.Helper()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
tracerProvider := sdktrace.NewTracerProvider()
|
||||
|
||||
runtime, err := telemetry.NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
return runtime, reader
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
require.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.AuthDirectoryStore = stubAuthDirectoryStore{}
|
||||
_ ports.Clock = fixedClock{}
|
||||
_ ports.IDGenerator = fixedIDGenerator{}
|
||||
_ ports.IDGenerator = (*sequenceIDGenerator)(nil)
|
||||
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
|
||||
_ ports.ProfileChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
|
||||
_ ports.SettingsChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
|
||||
_ ports.EntitlementChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
package entitlementsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReaderGetByUserIDPublishesExpiredRepairEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userID := common.UserID("user-123")
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(24 * time.Hour)
|
||||
now := endsAt.Add(time.Hour)
|
||||
snapshotStore := &fakeSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
userID: paidSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
),
|
||||
},
|
||||
}
|
||||
historyStore := &fakeHistoryStore{
|
||||
byUserID: map[common.UserID][]entitlement.PeriodRecord{
|
||||
userID: {
|
||||
paidRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
lifecycleStore := &fakeLifecycleStore{
|
||||
historyStore: historyStore,
|
||||
snapshotStore: snapshotStore,
|
||||
}
|
||||
publisher := &recordingEntitlementPublisher{}
|
||||
|
||||
reader, err := NewReaderWithObservability(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
|
||||
recordID: entitlement.EntitlementRecordID("entitlement-free"),
|
||||
}, nil, nil, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := reader.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
|
||||
require.Len(t, publisher.events, 1)
|
||||
require.Equal(t, ports.EntitlementChangedOperationExpiredRepaired, publisher.events[0].Operation)
|
||||
require.Equal(t, common.Source("entitlement_expiry_repair"), publisher.events[0].Source)
|
||||
}
|
||||
|
||||
func TestGrantServiceExecutePublisherFailureDoesNotRollbackResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
currentFreeStartsAt := now.Add(-24 * time.Hour)
|
||||
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
|
||||
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
|
||||
lifecycleStore := &fakeLifecycleStore{}
|
||||
publisher := &recordingEntitlementPublisher{err: errors.New("publisher unavailable")}
|
||||
|
||||
service, err := NewGrantServiceWithObservability(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
|
||||
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
|
||||
lifecycleStore,
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GrantInput{
|
||||
UserID: userID.String(),
|
||||
PlanCode: string(entitlement.PlanCodePaidMonthly),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_grant",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
StartsAt: now.Format(time.RFC3339Nano),
|
||||
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
|
||||
require.Len(t, publisher.events, 1)
|
||||
require.Equal(t, ports.EntitlementChangedOperationGranted, publisher.events[0].Operation)
|
||||
}
|
||||
|
||||
type recordingEntitlementPublisher struct {
|
||||
err error
|
||||
events []ports.EntitlementChangedEvent
|
||||
}
|
||||
|
||||
func (publisher *recordingEntitlementPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.events = append(publisher.events, event)
|
||||
return publisher.err
|
||||
}
|
||||
|
||||
var _ ports.EntitlementChangedPublisher = (*recordingEntitlementPublisher)(nil)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
package entitlementsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReaderGetByUserIDRepairsExpiredFinitePaidSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userID := common.UserID("user-123")
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(24 * time.Hour)
|
||||
now := endsAt.Add(2 * time.Hour)
|
||||
snapshotStore := &fakeSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
userID: paidSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
),
|
||||
},
|
||||
}
|
||||
historyStore := &fakeHistoryStore{
|
||||
byUserID: map[common.UserID][]entitlement.PeriodRecord{
|
||||
userID: {
|
||||
paidRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
lifecycleStore := &fakeLifecycleStore{
|
||||
historyStore: historyStore,
|
||||
snapshotStore: snapshotStore,
|
||||
}
|
||||
|
||||
reader, err := NewReader(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
|
||||
recordID: entitlement.EntitlementRecordID("entitlement-free"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := reader.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
|
||||
require.False(t, got.IsPaid)
|
||||
require.Equal(t, endsAt, got.StartsAt)
|
||||
require.Equal(t, expiryRepairSource, got.Source)
|
||||
require.Equal(t, expiryRepairReasonCode, got.ReasonCode)
|
||||
require.Equal(t, common.ActorRef{Type: expiryRepairActorType, ID: expiryRepairActorID}, got.Actor)
|
||||
require.Len(t, historyStore.byUserID[userID], 2)
|
||||
require.Equal(t, got, snapshotStore.byUserID[userID])
|
||||
require.Equal(t, entitlement.EntitlementRecordID("entitlement-free"), lifecycleStore.repairInput.NewRecord.RecordID)
|
||||
}
|
||||
|
||||
func TestGrantServiceExecuteRejectsInvalidPlanRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
freeSnapshot := freeSnapshot(userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
|
||||
freeRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input GrantInput
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "free plan not allowed",
|
||||
input: GrantInput{
|
||||
UserID: userID.String(),
|
||||
PlanCode: string(entitlement.PlanCodeFree),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_grant",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
StartsAt: now.Format(time.RFC3339Nano),
|
||||
},
|
||||
wantErr: shared.ErrorCodeInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "future starts at rejected",
|
||||
input: GrantInput{
|
||||
UserID: userID.String(),
|
||||
PlanCode: string(entitlement.PlanCodePaidMonthly),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_grant",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
StartsAt: now.Add(time.Hour).Format(time.RFC3339Nano),
|
||||
EndsAt: now.Add(31 * 24 * time.Hour).Format(time.RFC3339Nano),
|
||||
},
|
||||
wantErr: shared.ErrorCodeInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "finite plan requires ends at",
|
||||
input: GrantInput{
|
||||
UserID: userID.String(),
|
||||
PlanCode: string(entitlement.PlanCodePaidMonthly),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_grant",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
StartsAt: now.Format(time.RFC3339Nano),
|
||||
},
|
||||
wantErr: shared.ErrorCodeInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "lifetime plan forbids ends at",
|
||||
input: GrantInput{
|
||||
UserID: userID.String(),
|
||||
PlanCode: string(entitlement.PlanCodePaidLifetime),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_grant",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
StartsAt: now.Format(time.RFC3339Nano),
|
||||
EndsAt: now.Add(24 * time.Hour).Format(time.RFC3339Nano),
|
||||
},
|
||||
wantErr: shared.ErrorCodeInvalidRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewGrantService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {freeRecord}}},
|
||||
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: freeSnapshot}},
|
||||
&fakeLifecycleStore{},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), tt.input)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tt.wantErr, shared.CodeOf(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantServiceExecuteBuildsTransition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
currentFreeStartsAt := now.Add(-24 * time.Hour)
|
||||
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
|
||||
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
|
||||
lifecycleStore := &fakeLifecycleStore{}
|
||||
|
||||
service, err := NewGrantService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
|
||||
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
|
||||
lifecycleStore,
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GrantInput{
|
||||
UserID: userID.String(),
|
||||
PlanCode: string(entitlement.PlanCodePaidMonthly),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_grant",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
StartsAt: now.Format(time.RFC3339Nano),
|
||||
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID.String(), result.UserID)
|
||||
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
|
||||
require.Equal(t, entitlement.EntitlementRecordID("entitlement-paid"), lifecycleStore.grantInput.NewRecord.RecordID)
|
||||
require.Equal(t, currentSnapshot, lifecycleStore.grantInput.ExpectedCurrentSnapshot)
|
||||
require.Equal(t, currentRecord.RecordID, lifecycleStore.grantInput.UpdatedCurrentRecord.RecordID)
|
||||
require.NotNil(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt)
|
||||
require.True(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
|
||||
}
|
||||
|
||||
func TestExtendServiceExecuteBuildsExtensionSegment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
startsAt := now.Add(-24 * time.Hour)
|
||||
currentEndsAt := now.Add(24 * time.Hour)
|
||||
currentSnapshot := paidSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
currentEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
currentRecord := paidRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
currentEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
lifecycleStore := &fakeLifecycleStore{}
|
||||
|
||||
service, err := NewExtendService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
|
||||
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
|
||||
lifecycleStore,
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid-2")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), ExtendInput{
|
||||
UserID: userID.String(),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_extend",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
EndsAt: currentEndsAt.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, currentEndsAt, lifecycleStore.extendInput.NewRecord.StartsAt)
|
||||
require.Equal(t, startsAt, lifecycleStore.extendInput.NewSnapshot.StartsAt)
|
||||
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
|
||||
}
|
||||
|
||||
func TestRevokeServiceExecuteBuildsFreeTransition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
startsAt := now.Add(-24 * time.Hour)
|
||||
currentEndsAt := now.Add(24 * time.Hour)
|
||||
currentSnapshot := paidSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
currentEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
currentRecord := paidRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
currentEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
lifecycleStore := &fakeLifecycleStore{}
|
||||
|
||||
service, err := NewRevokeService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
|
||||
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
|
||||
lifecycleStore,
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-2")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), RevokeInput{
|
||||
UserID: userID.String(),
|
||||
Source: "admin",
|
||||
ReasonCode: "manual_revoke",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodeFree, result.Entitlement.PlanCode)
|
||||
require.NotNil(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt)
|
||||
require.True(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
|
||||
require.Equal(t, now, lifecycleStore.revokeInput.NewRecord.StartsAt)
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
existsByUserID map[common.UserID]bool
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
return store.existsByUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSnapshotStore struct {
|
||||
byUserID map[common.UserID]entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
func (store *fakeSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
||||
record, ok := store.byUserID[userID]
|
||||
if !ok {
|
||||
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
|
||||
store.byUserID[record.UserID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeHistoryStore struct {
|
||||
byUserID map[common.UserID][]entitlement.PeriodRecord
|
||||
}
|
||||
|
||||
func (store *fakeHistoryStore) Create(_ context.Context, record entitlement.PeriodRecord) error {
|
||||
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeHistoryStore) GetByRecordID(_ context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error) {
|
||||
for _, records := range store.byUserID {
|
||||
for _, record := range records {
|
||||
if record.RecordID == recordID {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entitlement.PeriodRecord{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeHistoryStore) ListByUserID(_ context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error) {
|
||||
records := store.byUserID[userID]
|
||||
cloned := make([]entitlement.PeriodRecord, len(records))
|
||||
copy(cloned, records)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (store *fakeHistoryStore) Update(_ context.Context, record entitlement.PeriodRecord) error {
|
||||
records := store.byUserID[record.UserID]
|
||||
for idx := range records {
|
||||
if records[idx].RecordID == record.RecordID {
|
||||
records[idx] = record
|
||||
store.byUserID[record.UserID] = records
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
|
||||
type fakeEffectiveReader struct {
|
||||
byUserID map[common.UserID]entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
func (reader fakeEffectiveReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
||||
record, ok := reader.byUserID[userID]
|
||||
if !ok {
|
||||
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
type fakeLifecycleStore struct {
|
||||
historyStore *fakeHistoryStore
|
||||
snapshotStore *fakeSnapshotStore
|
||||
|
||||
grantInput ports.GrantEntitlementInput
|
||||
extendInput ports.ExtendEntitlementInput
|
||||
revokeInput ports.RevokeEntitlementInput
|
||||
repairInput ports.RepairExpiredEntitlementInput
|
||||
}
|
||||
|
||||
func (store *fakeLifecycleStore) Grant(_ context.Context, input ports.GrantEntitlementInput) error {
|
||||
store.grantInput = input
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeLifecycleStore) Extend(_ context.Context, input ports.ExtendEntitlementInput) error {
|
||||
store.extendInput = input
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeLifecycleStore) Revoke(_ context.Context, input ports.RevokeEntitlementInput) error {
|
||||
store.revokeInput = input
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeLifecycleStore) RepairExpired(_ context.Context, input ports.RepairExpiredEntitlementInput) error {
|
||||
store.repairInput = input
|
||||
if store.historyStore != nil {
|
||||
store.historyStore.byUserID[input.NewRecord.UserID] = append(store.historyStore.byUserID[input.NewRecord.UserID], input.NewRecord)
|
||||
}
|
||||
if store.snapshotStore != nil {
|
||||
store.snapshotStore.byUserID[input.NewSnapshot.UserID] = input.NewSnapshot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type fixedIDGenerator struct {
|
||||
recordID entitlement.EntitlementRecordID
|
||||
sanctionRecordID policy.SanctionRecordID
|
||||
limitRecordID policy.LimitRecordID
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return generator.recordID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return generator.sanctionRecordID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return generator.limitRecordID, nil
|
||||
}
|
||||
|
||||
func freeSnapshot(
|
||||
userID common.UserID,
|
||||
startsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: startsAt,
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: reasonCode,
|
||||
UpdatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func freeRecord(
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
userID common.UserID,
|
||||
startsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.PeriodRecord {
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: recordID,
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: reasonCode,
|
||||
StartsAt: startsAt,
|
||||
CreatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func paidSnapshot(
|
||||
userID common.UserID,
|
||||
planCode entitlement.PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: planCode,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: timePointer(endsAt),
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: reasonCode,
|
||||
UpdatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func paidRecord(
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
userID common.UserID,
|
||||
planCode entitlement.PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.PeriodRecord {
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: recordID,
|
||||
UserID: userID,
|
||||
PlanCode: planCode,
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: reasonCode,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: timePointer(endsAt),
|
||||
CreatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
utcValue := value.UTC()
|
||||
return &utcValue
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.UserAccountStore = fakeAccountStore{}
|
||||
_ ports.EntitlementSnapshotStore = (*fakeSnapshotStore)(nil)
|
||||
_ ports.EntitlementHistoryStore = (*fakeHistoryStore)(nil)
|
||||
_ ports.EntitlementLifecycleStore = (*fakeLifecycleStore)(nil)
|
||||
_ ports.Clock = fixedClock{}
|
||||
_ ports.IDGenerator = fixedIDGenerator{}
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package geosync implements the trusted geo-facing declared-country sync
|
||||
// command owned by User Service.
|
||||
package geosync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const geoProfileServiceSource = common.Source("geo_profile_service")
|
||||
|
||||
// SyncDeclaredCountryInput stores one trusted geo-facing country-sync request.
|
||||
type SyncDeclaredCountryInput struct {
|
||||
// UserID identifies the regular user whose current declared country must be
|
||||
// synchronized.
|
||||
UserID string
|
||||
|
||||
// DeclaredCountry stores the new current effective declared country.
|
||||
DeclaredCountry string
|
||||
}
|
||||
|
||||
// SyncDeclaredCountryResult stores one trusted geo-facing country-sync result.
|
||||
type SyncDeclaredCountryResult struct {
|
||||
// UserID identifies the synchronized user.
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// DeclaredCountry stores the current effective declared country after the
|
||||
// command completes.
|
||||
DeclaredCountry string `json:"declared_country"`
|
||||
|
||||
// UpdatedAt stores the effective account mutation timestamp. Same-value
|
||||
// no-op syncs return the current stored timestamp unchanged.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SyncService executes the trusted geo-facing declared-country sync command.
|
||||
type SyncService struct {
|
||||
accounts ports.UserAccountStore
|
||||
clock ports.Clock
|
||||
publisher ports.DeclaredCountryChangedPublisher
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// NewSyncService constructs one trusted declared-country sync command.
|
||||
func NewSyncService(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
publisher ports.DeclaredCountryChangedPublisher,
|
||||
) (*SyncService, error) {
|
||||
return NewSyncServiceWithObservability(accounts, clock, publisher, nil, nil)
|
||||
}
|
||||
|
||||
// NewSyncServiceWithObservability constructs one trusted declared-country sync
|
||||
// command with optional structured logging and event-publication metrics.
|
||||
func NewSyncServiceWithObservability(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
publisher ports.DeclaredCountryChangedPublisher,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*SyncService, error) {
|
||||
switch {
|
||||
case accounts == nil:
|
||||
return nil, fmt.Errorf("geo declared-country sync service: user account store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("geo declared-country sync service: clock must not be nil")
|
||||
case publisher == nil:
|
||||
return nil, fmt.Errorf("geo declared-country sync service: declared-country changed publisher must not be nil")
|
||||
default:
|
||||
return &SyncService{
|
||||
accounts: accounts,
|
||||
clock: clock,
|
||||
publisher: publisher,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute synchronizes the current effective declared country of one user.
|
||||
func (service *SyncService) Execute(
|
||||
ctx context.Context,
|
||||
input SyncDeclaredCountryInput,
|
||||
) (result SyncDeclaredCountryResult, err error) {
|
||||
outcome := "failed"
|
||||
userIDString := ""
|
||||
defer func() {
|
||||
shared.LogServiceOutcome(service.logger, ctx, "declared-country sync completed", err,
|
||||
"use_case", "sync_declared_country",
|
||||
"outcome", outcome,
|
||||
"user_id", userIDString,
|
||||
"source", geoProfileServiceSource.String(),
|
||||
)
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
return SyncDeclaredCountryResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return SyncDeclaredCountryResult{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
declaredCountry, err := parseDeclaredCountry(input.DeclaredCountry)
|
||||
if err != nil {
|
||||
return SyncDeclaredCountryResult{}, err
|
||||
}
|
||||
|
||||
record, err := service.accounts.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
|
||||
default:
|
||||
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
if record.DeclaredCountry == declaredCountry {
|
||||
outcome = "noop"
|
||||
return resultFromAccount(record), nil
|
||||
}
|
||||
|
||||
record.DeclaredCountry = declaredCountry
|
||||
record.UpdatedAt = service.clock.Now().UTC()
|
||||
|
||||
if err := service.accounts.Update(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
|
||||
default:
|
||||
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
result = resultFromAccount(record)
|
||||
outcome = "updated"
|
||||
|
||||
if err := service.publisher.PublishDeclaredCountryChanged(ctx, ports.DeclaredCountryChangedEvent{
|
||||
UserID: record.UserID,
|
||||
DeclaredCountry: record.DeclaredCountry,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
Source: geoProfileServiceSource,
|
||||
}); err != nil {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordEventPublicationFailure(ctx, ports.DeclaredCountryChangedEventType)
|
||||
}
|
||||
shared.LogEventPublicationFailure(service.logger, ctx, ports.DeclaredCountryChangedEventType, err,
|
||||
"use_case", "sync_declared_country",
|
||||
"user_id", record.UserID.String(),
|
||||
"source", geoProfileServiceSource.String(),
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseDeclaredCountry(value string) (common.CountryCode, error) {
|
||||
const message = "declared_country must be a valid ISO 3166-1 alpha-2 country code"
|
||||
|
||||
code := common.CountryCode(shared.NormalizeString(value))
|
||||
if err := code.Validate(); err != nil {
|
||||
return "", shared.InvalidRequest(message)
|
||||
}
|
||||
|
||||
region, err := language.ParseRegion(code.String())
|
||||
if err != nil || !region.IsCountry() || region.Canonicalize().String() != code.String() {
|
||||
return "", shared.InvalidRequest(message)
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func resultFromAccount(record account.UserAccount) SyncDeclaredCountryResult {
|
||||
return SyncDeclaredCountryResult{
|
||||
UserID: record.UserID.String(),
|
||||
DeclaredCountry: record.DeclaredCountry.String(),
|
||||
UpdatedAt: record.UpdatedAt.UTC(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package geosync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSyncServiceExecuteUpdatesDeclaredCountryAndPublishesEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
updatedAt := createdAt.Add(5 * time.Minute)
|
||||
record := validAccountRecord(createdAt, createdAt)
|
||||
store := newFakeAccountStore(record)
|
||||
publisher := &recordingDeclaredCountryChangedPublisher{
|
||||
publishHook: func(event ports.DeclaredCountryChangedEvent) error {
|
||||
stored, err := store.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
|
||||
require.Equal(t, updatedAt, stored.UpdatedAt)
|
||||
require.Equal(t, common.Source("geo_profile_service"), event.Source)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
|
||||
UserID: record.UserID.String(),
|
||||
DeclaredCountry: "FR",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID.String(), result.UserID)
|
||||
require.Equal(t, "FR", result.DeclaredCountry)
|
||||
require.Equal(t, updatedAt, result.UpdatedAt)
|
||||
require.Equal(t, 1, store.updateCalls)
|
||||
|
||||
stored, err := store.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.Email, stored.Email)
|
||||
require.Equal(t, record.RaceName, stored.RaceName)
|
||||
require.Equal(t, record.PreferredLanguage, stored.PreferredLanguage)
|
||||
require.Equal(t, record.TimeZone, stored.TimeZone)
|
||||
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
|
||||
require.Equal(t, record.CreatedAt, stored.CreatedAt)
|
||||
require.Equal(t, updatedAt, stored.UpdatedAt)
|
||||
|
||||
published := publisher.PublishedEvents()
|
||||
require.Len(t, published, 1)
|
||||
require.Equal(t, record.UserID, published[0].UserID)
|
||||
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
|
||||
require.Equal(t, updatedAt, published[0].UpdatedAt)
|
||||
require.Equal(t, common.Source("geo_profile_service"), published[0].Source)
|
||||
}
|
||||
|
||||
func TestSyncServiceExecuteSameCountryIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord(createdAt, createdAt.Add(5*time.Minute))
|
||||
store := newFakeAccountStore(record)
|
||||
publisher := &recordingDeclaredCountryChangedPublisher{}
|
||||
|
||||
service, err := NewSyncService(store, fixedClock{now: createdAt.Add(time.Hour)}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
|
||||
UserID: record.UserID.String(),
|
||||
DeclaredCountry: record.DeclaredCountry.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID.String(), result.UserID)
|
||||
require.Equal(t, record.DeclaredCountry.String(), result.DeclaredCountry)
|
||||
require.Equal(t, record.UpdatedAt, result.UpdatedAt)
|
||||
require.Zero(t, store.updateCalls)
|
||||
require.Empty(t, publisher.PublishedEvents())
|
||||
}
|
||||
|
||||
func TestSyncServiceExecuteRejectsInvalidDeclaredCountry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewSyncService(
|
||||
newFakeAccountStore(validAccountRecord(time.Unix(1_775_240_000, 0).UTC(), time.Unix(1_775_240_000, 0).UTC())),
|
||||
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
|
||||
&recordingDeclaredCountryChangedPublisher{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "alias country code", value: "UK"},
|
||||
{name: "lowercase", value: "de"},
|
||||
{name: "non-country region", value: "EU"},
|
||||
{name: "wrong length", value: "DEU"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
|
||||
UserID: "user-123",
|
||||
DeclaredCountry: tt.value,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
require.EqualError(t, err, "declared_country must be a valid ISO 3166-1 alpha-2 country code")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncServiceExecuteUnknownUserReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewSyncService(
|
||||
newFakeAccountStore(),
|
||||
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
|
||||
&recordingDeclaredCountryChangedPublisher{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), SyncDeclaredCountryInput{
|
||||
UserID: "user-missing",
|
||||
DeclaredCountry: "DE",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestSyncServiceExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
record := validAccountRecord(createdAt, createdAt)
|
||||
store := newFakeAccountStore(record)
|
||||
publisher := &recordingDeclaredCountryChangedPublisher{
|
||||
err: errors.New("publisher unavailable"),
|
||||
}
|
||||
|
||||
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
|
||||
UserID: record.UserID.String(),
|
||||
DeclaredCountry: "FR",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "FR", result.DeclaredCountry)
|
||||
require.Equal(t, updatedAt, result.UpdatedAt)
|
||||
|
||||
stored, err := store.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
|
||||
require.Equal(t, updatedAt, stored.UpdatedAt)
|
||||
|
||||
published := publisher.PublishedEvents()
|
||||
require.Len(t, published, 1)
|
||||
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
records map[common.UserID]account.UserAccount
|
||||
updateCalls int
|
||||
updateErr error
|
||||
}
|
||||
|
||||
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
|
||||
byUserID := make(map[common.UserID]account.UserAccount, len(records))
|
||||
for _, record := range records {
|
||||
byUserID[record.UserID] = record
|
||||
}
|
||||
|
||||
return &fakeAccountStore{records: byUserID}
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
|
||||
record, ok := store.records[userID]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.Email == email {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.RaceName == raceName {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
_, ok := store.records[userID]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
|
||||
store.updateCalls++
|
||||
if store.updateErr != nil {
|
||||
return store.updateErr
|
||||
}
|
||||
if _, ok := store.records[record.UserID]; !ok {
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
store.records[record.UserID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingDeclaredCountryChangedPublisher struct {
|
||||
err error
|
||||
publishHook func(event ports.DeclaredCountryChangedEvent) error
|
||||
published []ports.DeclaredCountryChangedEvent
|
||||
}
|
||||
|
||||
func (publisher *recordingDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
|
||||
_ context.Context,
|
||||
event ports.DeclaredCountryChangedEvent,
|
||||
) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publisher.published = append(publisher.published, event)
|
||||
if publisher.publishHook != nil {
|
||||
if err := publisher.publishHook(event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return publisher.err
|
||||
}
|
||||
|
||||
func (publisher *recordingDeclaredCountryChangedPublisher) PublishedEvents() []ports.DeclaredCountryChangedEvent {
|
||||
events := make([]ports.DeclaredCountryChangedEvent, len(publisher.published))
|
||||
copy(events, publisher.published)
|
||||
return events
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
func validAccountRecord(createdAt time.Time, updatedAt time.Time) account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
|
||||
_ ports.DeclaredCountryChangedPublisher = (*recordingDeclaredCountryChangedPublisher)(nil)
|
||||
_ ports.Clock = fixedClock{}
|
||||
)
|
||||
@@ -0,0 +1,397 @@
|
||||
// Package lobbyeligibility implements the trusted lobby-facing eligibility
|
||||
// snapshot read owned by User Service.
|
||||
package lobbyeligibility
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
)
|
||||
|
||||
// limitCatalogEntry stores one frozen default quota for free and paid
|
||||
// entitlement states.
|
||||
type limitCatalogEntry struct {
|
||||
code policy.LimitCode
|
||||
freeValue int
|
||||
paidValue int
|
||||
freeEnabled bool
|
||||
}
|
||||
|
||||
// limitCatalog stores the frozen lobby-facing effective limit defaults used
|
||||
// to materialize numeric quotas from the current entitlement state.
|
||||
var limitCatalog = []limitCatalogEntry{
|
||||
{
|
||||
code: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
paidValue: 3,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxPendingPublicApplications,
|
||||
freeValue: 3,
|
||||
paidValue: 10,
|
||||
freeEnabled: true,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxActiveGameMemberships,
|
||||
freeValue: 3,
|
||||
paidValue: 10,
|
||||
freeEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// ActorRefView stores transport-ready audit actor metadata.
|
||||
type ActorRefView struct {
|
||||
// Type stores the machine-readable actor type.
|
||||
Type string `json:"type"`
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// EntitlementSnapshotView stores the transport-ready current entitlement
|
||||
// snapshot used by lobby reads.
|
||||
type EntitlementSnapshotView struct {
|
||||
// PlanCode stores the effective entitlement plan code.
|
||||
PlanCode string `json:"plan_code"`
|
||||
|
||||
// IsPaid reports whether the effective plan is paid.
|
||||
IsPaid bool `json:"is_paid"`
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source string `json:"source"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the snapshot.
|
||||
Actor ActorRefView `json:"actor"`
|
||||
|
||||
// ReasonCode stores the machine-readable reason attached to the snapshot.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// StartsAt stores when the effective state started.
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
|
||||
// EndsAt stores the optional finite effective expiry.
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
|
||||
// UpdatedAt stores when the snapshot was last recomputed.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ActiveSanctionView stores one transport-ready active sanction that matters
|
||||
// to lobby flows.
|
||||
type ActiveSanctionView struct {
|
||||
// SanctionCode stores the active sanction code.
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
|
||||
// Scope stores the machine-readable sanction scope.
|
||||
Scope string `json:"scope"`
|
||||
|
||||
// ReasonCode stores the machine-readable sanction reason.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the sanction.
|
||||
Actor ActorRefView `json:"actor"`
|
||||
|
||||
// AppliedAt stores when the sanction became active.
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
|
||||
// ExpiresAt stores the optional planned sanction expiry.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveLimitView stores one materialized effective lobby quota.
|
||||
type EffectiveLimitView struct {
|
||||
// LimitCode stores the machine-readable quota identifier.
|
||||
LimitCode string `json:"limit_code"`
|
||||
|
||||
// Value stores the effective numeric quota after defaults and user
|
||||
// overrides are applied.
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// EligibilityMarkersView stores the derived booleans consumed by Game Lobby.
|
||||
type EligibilityMarkersView struct {
|
||||
// CanLogin reports whether the user may currently log in.
|
||||
CanLogin bool `json:"can_login"`
|
||||
|
||||
// CanCreatePrivateGame reports whether the user may currently create a
|
||||
// private game.
|
||||
CanCreatePrivateGame bool `json:"can_create_private_game"`
|
||||
|
||||
// CanManagePrivateGame reports whether the user may currently manage a
|
||||
// private game.
|
||||
CanManagePrivateGame bool `json:"can_manage_private_game"`
|
||||
|
||||
// CanJoinGame reports whether the user may currently join a game.
|
||||
CanJoinGame bool `json:"can_join_game"`
|
||||
|
||||
// CanUpdateProfile reports whether the user may currently update self-
|
||||
// service profile and settings fields.
|
||||
CanUpdateProfile bool `json:"can_update_profile"`
|
||||
}
|
||||
|
||||
// GetUserEligibilityInput stores one lobby-facing eligibility read request.
|
||||
type GetUserEligibilityInput struct {
|
||||
// UserID identifies the regular user whose effective lobby state is needed.
|
||||
UserID string
|
||||
}
|
||||
|
||||
// GetUserEligibilityResult stores one lobby-facing eligibility snapshot.
|
||||
type GetUserEligibilityResult struct {
|
||||
// Exists reports whether UserID currently identifies a stored user.
|
||||
Exists bool `json:"exists"`
|
||||
|
||||
// UserID echoes the requested stable user identifier.
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// Entitlement stores the current effective entitlement snapshot for known
|
||||
// users.
|
||||
Entitlement *EntitlementSnapshotView `json:"entitlement,omitempty"`
|
||||
|
||||
// ActiveSanctions stores only the currently active sanctions relevant to
|
||||
// lobby decisions.
|
||||
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
|
||||
|
||||
// EffectiveLimits stores the materialized numeric quotas used by Game
|
||||
// Lobby.
|
||||
EffectiveLimits []EffectiveLimitView `json:"effective_limits"`
|
||||
|
||||
// Markers stores the derived decision booleans consumed by Game Lobby.
|
||||
Markers EligibilityMarkersView `json:"markers"`
|
||||
}
|
||||
|
||||
type entitlementReader interface {
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
|
||||
}
|
||||
|
||||
// SnapshotReader executes the trusted lobby-facing eligibility snapshot read.
|
||||
type SnapshotReader struct {
|
||||
accounts ports.UserAccountStore
|
||||
entitlements entitlementReader
|
||||
sanctions ports.SanctionStore
|
||||
limits ports.LimitStore
|
||||
clock ports.Clock
|
||||
}
|
||||
|
||||
// NewSnapshotReader constructs one lobby-facing eligibility snapshot reader.
|
||||
func NewSnapshotReader(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*SnapshotReader, error) {
|
||||
switch {
|
||||
case accounts == nil:
|
||||
return nil, fmt.Errorf("lobby eligibility snapshot reader: user account store must not be nil")
|
||||
case entitlements == nil:
|
||||
return nil, fmt.Errorf("lobby eligibility snapshot reader: entitlement reader must not be nil")
|
||||
case sanctions == nil:
|
||||
return nil, fmt.Errorf("lobby eligibility snapshot reader: sanction store must not be nil")
|
||||
case limits == nil:
|
||||
return nil, fmt.Errorf("lobby eligibility snapshot reader: limit store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("lobby eligibility snapshot reader: clock must not be nil")
|
||||
default:
|
||||
return &SnapshotReader{
|
||||
accounts: accounts,
|
||||
entitlements: entitlements,
|
||||
sanctions: sanctions,
|
||||
limits: limits,
|
||||
clock: clock,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute returns one read-optimized eligibility snapshot for Game Lobby.
|
||||
func (service *SnapshotReader) Execute(
|
||||
ctx context.Context,
|
||||
input GetUserEligibilityInput,
|
||||
) (GetUserEligibilityResult, error) {
|
||||
if ctx == nil {
|
||||
return GetUserEligibilityResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return GetUserEligibilityResult{}, err
|
||||
}
|
||||
|
||||
result := GetUserEligibilityResult{
|
||||
UserID: userID.String(),
|
||||
ActiveSanctions: []ActiveSanctionView{},
|
||||
EffectiveLimits: []EffectiveLimitView{},
|
||||
}
|
||||
|
||||
exists, err := service.accounts.ExistsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if !exists {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC()
|
||||
|
||||
entitlementSnapshot, err := service.entitlements.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
|
||||
default:
|
||||
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
sanctionRecords, err := service.sanctions.ListByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
|
||||
if err != nil {
|
||||
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
|
||||
}
|
||||
|
||||
limitRecords, err := service.limits.ListByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
|
||||
if err != nil {
|
||||
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
|
||||
}
|
||||
|
||||
result.Exists = true
|
||||
result.Entitlement = entitlementSnapshotView(entitlementSnapshot)
|
||||
result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions)
|
||||
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits)
|
||||
result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func entitlementSnapshotView(snapshot entitlement.CurrentSnapshot) *EntitlementSnapshotView {
|
||||
return &EntitlementSnapshotView{
|
||||
PlanCode: string(snapshot.PlanCode),
|
||||
IsPaid: snapshot.IsPaid,
|
||||
Source: snapshot.Source.String(),
|
||||
Actor: actorRefView(snapshot.Actor),
|
||||
ReasonCode: snapshot.ReasonCode.String(),
|
||||
StartsAt: snapshot.StartsAt.UTC(),
|
||||
EndsAt: cloneOptionalTime(snapshot.EndsAt),
|
||||
UpdatedAt: snapshot.UpdatedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctionView {
|
||||
views := make([]ActiveSanctionView, 0, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
if !isLobbyRelevantSanction(record.SanctionCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
views = append(views, ActiveSanctionView{
|
||||
SanctionCode: string(record.SanctionCode),
|
||||
Scope: record.Scope.String(),
|
||||
ReasonCode: record.ReasonCode.String(),
|
||||
Actor: actorRefView(record.Actor),
|
||||
AppliedAt: record.AppliedAt.UTC(),
|
||||
ExpiresAt: cloneOptionalTime(record.ExpiresAt),
|
||||
})
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView {
|
||||
overrideValues := make(map[policy.LimitCode]int, len(overrides))
|
||||
for _, record := range overrides {
|
||||
overrideValues[record.LimitCode] = record.Value
|
||||
}
|
||||
|
||||
limits := make([]EffectiveLimitView, 0, len(limitCatalog))
|
||||
for _, entry := range limitCatalog {
|
||||
if !isPaid && !entry.freeEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
value := entry.freeValue
|
||||
if isPaid {
|
||||
value = entry.paidValue
|
||||
}
|
||||
if override, ok := overrideValues[entry.code]; ok {
|
||||
value = override
|
||||
}
|
||||
|
||||
limits = append(limits, EffectiveLimitView{
|
||||
LimitCode: string(entry.code),
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
return limits
|
||||
}
|
||||
|
||||
func deriveEligibilityMarkers(
|
||||
isPaid bool,
|
||||
activeSanctions []policy.SanctionRecord,
|
||||
) EligibilityMarkersView {
|
||||
loginBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeLoginBlock)
|
||||
createBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameCreateBlock)
|
||||
manageBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameManageBlock)
|
||||
joinBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeGameJoinBlock)
|
||||
profileBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeProfileUpdateBlock)
|
||||
|
||||
canLogin := !loginBlocked
|
||||
|
||||
return EligibilityMarkersView{
|
||||
CanLogin: canLogin,
|
||||
CanCreatePrivateGame: canLogin && isPaid && !createBlocked,
|
||||
CanManagePrivateGame: canLogin && isPaid && !manageBlocked,
|
||||
CanJoinGame: canLogin && !joinBlocked,
|
||||
CanUpdateProfile: canLogin && !profileBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
func hasActiveSanction(records []policy.SanctionRecord, code policy.SanctionCode) bool {
|
||||
for _, record := range records {
|
||||
if record.SanctionCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isLobbyRelevantSanction(code policy.SanctionCode) bool {
|
||||
switch code {
|
||||
case policy.SanctionCodeLoginBlock,
|
||||
policy.SanctionCodePrivateGameCreateBlock,
|
||||
policy.SanctionCodePrivateGameManageBlock,
|
||||
policy.SanctionCodeGameJoinBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func actorRefView(actor common.ActorRef) ActorRefView {
|
||||
return ActorRefView{
|
||||
Type: actor.Type.String(),
|
||||
ID: actor.ID.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneOptionalTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := value.UTC()
|
||||
return &cloned
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package lobbyeligibility
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/adapters/redis/userstore"
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSnapshotReaderExecuteReturnsStableNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewSnapshotReader(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
|
||||
fakeEntitlementReader{},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: " user-missing "})
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.Exists)
|
||||
require.Equal(t, "user-missing", result.UserID)
|
||||
require.Nil(t, result.Entitlement)
|
||||
require.Empty(t, result.ActiveSanctions)
|
||||
require.Empty(t, result.EffectiveLimits)
|
||||
require.Equal(t, EligibilityMarkersView{}, result.Markers)
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
service, err := NewSnapshotReader(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
fakeEntitlementReader{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
userID: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{
|
||||
byUserID: map[common.UserID][]policy.SanctionRecord{
|
||||
userID: {
|
||||
activeSanction(userID, policy.SanctionCodePrivateGameManageBlock, "lobby", now.Add(-time.Hour)),
|
||||
activeSanction(userID, policy.SanctionCodeProfileUpdateBlock, "profile", now.Add(-30*time.Minute)),
|
||||
expiredSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-2*time.Hour)),
|
||||
},
|
||||
},
|
||||
},
|
||||
fakeLimitStore{
|
||||
byUserID: map[common.UserID][]policy.LimitRecord{
|
||||
userID: {
|
||||
activeLimit(userID, policy.LimitCodeMaxPendingPrivateInvitesSent, 17, now.Add(-time.Hour)),
|
||||
activeLimit(userID, policy.LimitCodeMaxActivePrivateGames, 2, now.Add(-2*time.Hour)),
|
||||
},
|
||||
},
|
||||
},
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Exists)
|
||||
require.NotNil(t, result.Entitlement)
|
||||
require.Equal(t, "paid_monthly", result.Entitlement.PlanCode)
|
||||
require.True(t, result.Entitlement.IsPaid)
|
||||
|
||||
require.Len(t, result.ActiveSanctions, 1)
|
||||
require.Equal(t, "private_game_manage_block", result.ActiveSanctions[0].SanctionCode)
|
||||
|
||||
require.Equal(t, EligibilityMarkersView{
|
||||
CanLogin: true,
|
||||
CanCreatePrivateGame: true,
|
||||
CanManagePrivateGame: false,
|
||||
CanJoinGame: true,
|
||||
CanUpdateProfile: false,
|
||||
}, result.Markers)
|
||||
|
||||
require.Equal(t, []EffectiveLimitView{
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
}, result.EffectiveLimits)
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
snapshot entitlement.CurrentSnapshot
|
||||
sanctions []policy.SanctionRecord
|
||||
limits []policy.LimitRecord
|
||||
wantSanctions []string
|
||||
wantMarkers EligibilityMarkersView
|
||||
wantLimits []EffectiveLimitView
|
||||
}{
|
||||
{
|
||||
name: "unpaid defaults",
|
||||
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
|
||||
limits: []policy.LimitRecord{activeLimit(userID, policy.LimitCodeMaxOwnedPrivateGames, 9, now.Add(-time.Hour))},
|
||||
wantSanctions: []string{},
|
||||
wantMarkers: EligibilityMarkersView{
|
||||
CanLogin: true,
|
||||
CanCreatePrivateGame: false,
|
||||
CanManagePrivateGame: false,
|
||||
CanJoinGame: true,
|
||||
CanUpdateProfile: true,
|
||||
},
|
||||
wantLimits: []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "login block denies all markers",
|
||||
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)),
|
||||
activeSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-30*time.Minute)),
|
||||
},
|
||||
wantSanctions: []string{"game_join_block", "login_block"},
|
||||
wantMarkers: EligibilityMarkersView{
|
||||
CanLogin: false,
|
||||
CanCreatePrivateGame: false,
|
||||
CanManagePrivateGame: false,
|
||||
CanJoinGame: false,
|
||||
CanUpdateProfile: false,
|
||||
},
|
||||
wantLimits: []EffectiveLimitView{
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewSnapshotReader(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}},
|
||||
fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}},
|
||||
fakeLimitStore{byUserID: map[common.UserID][]policy.LimitRecord{userID: tt.limits}},
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantMarkers, result.Markers)
|
||||
require.Equal(t, tt.wantLimits, result.EffectiveLimits)
|
||||
|
||||
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
|
||||
for _, sanction := range result.ActiveSanctions {
|
||||
gotSanctions = append(gotSanctions, sanction.SanctionCode)
|
||||
}
|
||||
require.Equal(t, tt.wantSanctions, gotSanctions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
store := newRedisStore(t)
|
||||
userID := common.UserID("user-123")
|
||||
accountRecord := validAccountRecord()
|
||||
|
||||
require.NoError(t, store.Accounts().Create(context.Background(), ports.CreateAccountInput{
|
||||
Account: accountRecord,
|
||||
Reservation: account.RaceNameReservation{
|
||||
CanonicalKey: account.RaceNameCanonicalKey("pilot nova"),
|
||||
UserID: userID,
|
||||
RaceName: accountRecord.RaceName,
|
||||
ReservedAt: accountRecord.UpdatedAt,
|
||||
},
|
||||
}))
|
||||
|
||||
expiredEndsAt := now.Add(-time.Minute)
|
||||
require.NoError(t, store.EntitlementSnapshots().Put(context.Background(), entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: now.Add(-30 * 24 * time.Hour),
|
||||
EndsAt: timePointer(expiredEndsAt),
|
||||
Source: common.Source("billing"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
|
||||
ReasonCode: common.ReasonCode("renewal"),
|
||||
UpdatedAt: now.Add(-2 * time.Hour),
|
||||
}))
|
||||
|
||||
entitlementReader, err := entitlementsvc.NewReader(
|
||||
store.EntitlementSnapshots(),
|
||||
store.EntitlementLifecycle(),
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-expiry-repair")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := NewSnapshotReader(
|
||||
store.Accounts(),
|
||||
entitlementReader,
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Exists)
|
||||
require.NotNil(t, result.Entitlement)
|
||||
require.Equal(t, "free", result.Entitlement.PlanCode)
|
||||
require.False(t, result.Entitlement.IsPaid)
|
||||
require.Equal(t, expiredEndsAt, result.Entitlement.StartsAt)
|
||||
require.Equal(t, []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
}, result.EffectiveLimits)
|
||||
|
||||
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
|
||||
require.False(t, storedSnapshot.IsPaid)
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
existsByUserID map[common.UserID]bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
if store.err != nil {
|
||||
return false, store.err
|
||||
}
|
||||
|
||||
return store.existsByUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEntitlementReader struct {
|
||||
byUserID map[common.UserID]entitlement.CurrentSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (reader fakeEntitlementReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
||||
if reader.err != nil {
|
||||
return entitlement.CurrentSnapshot{}, reader.err
|
||||
}
|
||||
|
||||
record, ok := reader.byUserID[userID]
|
||||
if !ok {
|
||||
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
type fakeSanctionStore struct {
|
||||
byUserID map[common.UserID][]policy.SanctionRecord
|
||||
err error
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
|
||||
return policy.SanctionRecord{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
|
||||
if store.err != nil {
|
||||
return nil, store.err
|
||||
}
|
||||
|
||||
records := store.byUserID[userID]
|
||||
cloned := make([]policy.SanctionRecord, len(records))
|
||||
copy(cloned, records)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeLimitStore struct {
|
||||
byUserID map[common.UserID][]policy.LimitRecord
|
||||
err error
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
|
||||
return policy.LimitRecord{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
|
||||
if store.err != nil {
|
||||
return nil, store.err
|
||||
}
|
||||
|
||||
records := store.byUserID[userID]
|
||||
cloned := make([]policy.LimitRecord, len(records))
|
||||
copy(cloned, records)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type fixedIDGenerator struct {
|
||||
entitlementRecordID entitlement.EntitlementRecordID
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return generator.entitlementRecordID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func activeSanction(
|
||||
userID common.UserID,
|
||||
code policy.SanctionCode,
|
||||
scope string,
|
||||
appliedAt time.Time,
|
||||
) policy.SanctionRecord {
|
||||
return policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
|
||||
UserID: userID,
|
||||
SanctionCode: code,
|
||||
Scope: common.Scope(scope),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: appliedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func expiredSanction(
|
||||
userID common.UserID,
|
||||
code policy.SanctionCode,
|
||||
scope string,
|
||||
appliedAt time.Time,
|
||||
) policy.SanctionRecord {
|
||||
record := activeSanction(userID, code, scope, appliedAt)
|
||||
expiresAt := appliedAt.Add(30 * time.Minute)
|
||||
record.ExpiresAt = &expiresAt
|
||||
return record
|
||||
}
|
||||
|
||||
func activeLimit(
|
||||
userID common.UserID,
|
||||
code policy.LimitCode,
|
||||
value int,
|
||||
appliedAt time.Time,
|
||||
) policy.LimitRecord {
|
||||
return policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-" + string(code)),
|
||||
UserID: userID,
|
||||
LimitCode: code,
|
||||
Value: value,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: appliedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func removedLimit(
|
||||
userID common.UserID,
|
||||
code policy.LimitCode,
|
||||
value int,
|
||||
appliedAt time.Time,
|
||||
) policy.LimitRecord {
|
||||
record := activeLimit(userID, code, value, appliedAt)
|
||||
removedAt := appliedAt.Add(15 * time.Minute)
|
||||
record.RemovedAt = &removedAt
|
||||
record.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
record.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
return record
|
||||
}
|
||||
|
||||
func paidEntitlementSnapshot(
|
||||
userID common.UserID,
|
||||
startsAt time.Time,
|
||||
endsAt time.Time,
|
||||
) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: timePointer(endsAt),
|
||||
Source: common.Source("billing"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
|
||||
ReasonCode: common.ReasonCode("renewal"),
|
||||
UpdatedAt: startsAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func freeEntitlementSnapshot(userID common.UserID, startsAt time.Time) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: startsAt.UTC(),
|
||||
Source: common.Source("auth_registration"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
UpdatedAt: startsAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func validAccountRecord() account.UserAccount {
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func newRedisStore(t *testing.T) *userstore.Store {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
store, err := userstore.New(userstore.Config{
|
||||
Addr: server.Addr(),
|
||||
DB: 0,
|
||||
KeyspacePrefix: "user:test:",
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
utcValue := value.UTC()
|
||||
return &utcValue
|
||||
}
|
||||
|
||||
var _ ports.UserAccountStore = fakeAccountStore{}
|
||||
var _ ports.SanctionStore = fakeSanctionStore{}
|
||||
var _ ports.LimitStore = fakeLimitStore{}
|
||||
var _ ports.Clock = fixedClock{}
|
||||
var _ ports.IDGenerator = fixedIDGenerator{}
|
||||
@@ -0,0 +1,178 @@
|
||||
package policysvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
publisher := &recordingPolicyPublisher{}
|
||||
|
||||
service, err := NewApplySanctionServiceWithObservability(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), ApplySanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
Scope: "auth",
|
||||
ReasonCode: "policy_blocked",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, publisher.sanctionEvents, 1)
|
||||
require.Equal(t, ports.SanctionChangedOperationApplied, publisher.sanctionEvents[0].Operation)
|
||||
require.Equal(t, common.Source("admin_internal_api"), publisher.sanctionEvents[0].Source)
|
||||
}
|
||||
|
||||
func TestRemoveSanctionServiceExecuteMissingDoesNotPublishEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
publisher := &recordingPolicyPublisher{}
|
||||
|
||||
service, err := NewRemoveSanctionServiceWithObservability(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), RemoveSanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
ReasonCode: "manual_remove",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, publisher.sanctionEvents)
|
||||
}
|
||||
|
||||
func TestSetLimitServiceExecutePublishesEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
publisher := &recordingPolicyPublisher{}
|
||||
|
||||
service, err := NewSetLimitServiceWithObservability(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-1")},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), SetLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
|
||||
Value: 5,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, publisher.limitEvents, 1)
|
||||
require.Equal(t, ports.LimitChangedOperationSet, publisher.limitEvents[0].Operation)
|
||||
require.NotNil(t, publisher.limitEvents[0].Value)
|
||||
require.Equal(t, 5, *publisher.limitEvents[0].Value)
|
||||
}
|
||||
|
||||
func TestRemoveLimitServiceExecuteMissingDoesNotPublishEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
publisher := &recordingPolicyPublisher{}
|
||||
|
||||
service, err := NewRemoveLimitServiceWithObservability(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), RemoveLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
|
||||
ReasonCode: "manual_remove",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, publisher.limitEvents)
|
||||
}
|
||||
|
||||
type recordingPolicyPublisher struct {
|
||||
sanctionEvents []ports.SanctionChangedEvent
|
||||
limitEvents []ports.LimitChangedEvent
|
||||
}
|
||||
|
||||
func (publisher *recordingPolicyPublisher) PublishSanctionChanged(_ context.Context, event ports.SanctionChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.sanctionEvents = append(publisher.sanctionEvents, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (publisher *recordingPolicyPublisher) PublishLimitChanged(_ context.Context, event ports.LimitChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.limitEvents = append(publisher.limitEvents, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.SanctionChangedPublisher = (*recordingPolicyPublisher)(nil)
|
||||
_ ports.LimitChangedPublisher = (*recordingPolicyPublisher)(nil)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,705 @@
|
||||
package policysvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplySanctionServiceExecuteBuildsActiveRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
|
||||
service, err := NewApplySanctionService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), ApplySanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
Scope: "auth",
|
||||
ReasonCode: "policy_blocked",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID.String(), result.UserID)
|
||||
require.Len(t, result.ActiveSanctions, 1)
|
||||
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.ActiveSanctions[0].SanctionCode)
|
||||
|
||||
records, err := sanctionStore.ListByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 1)
|
||||
require.Equal(t, policy.SanctionRecordID("sanction-1"), records[0].RecordID)
|
||||
}
|
||||
|
||||
func TestApplySanctionServiceExecuteRejectsExpiredSanction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
|
||||
service, err := NewApplySanctionService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), ApplySanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
Scope: "auth",
|
||||
ReasonCode: "policy_blocked",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestApplySanctionServiceExecuteReturnsConflictWhenActiveSanctionExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
existing := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-existing"),
|
||||
UserID: userID,
|
||||
SanctionCode: policy.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),
|
||||
}
|
||||
require.NoError(t, sanctionStore.Create(context.Background(), existing))
|
||||
|
||||
service, err := NewApplySanctionService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
newFakeLimitStore(),
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: newFakeLimitStore()},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), ApplySanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
Scope: "auth",
|
||||
ReasonCode: "policy_blocked",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-2"},
|
||||
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestApplySanctionServiceExecuteReturnsNotFoundForUnknownUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
service, err := NewApplySanctionService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
|
||||
newFakeSanctionStore(),
|
||||
newFakeLimitStore(),
|
||||
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), ApplySanctionInput{
|
||||
UserID: "user-missing",
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
Scope: "auth",
|
||||
ReasonCode: "policy_blocked",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
AppliedAt: now.Format(time.RFC3339Nano),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestRemoveSanctionServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
|
||||
service, err := NewRemoveSanctionService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), RemoveSanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
ReasonCode: "manual_remove",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID.String(), result.UserID)
|
||||
require.Empty(t, result.ActiveSanctions)
|
||||
}
|
||||
|
||||
func TestRemoveSanctionServiceExecuteTreatsConcurrentRemovalAsSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
record := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-1"),
|
||||
UserID: userID,
|
||||
SanctionCode: policy.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),
|
||||
}
|
||||
require.NoError(t, sanctionStore.Create(context.Background(), record))
|
||||
|
||||
lifecycle := &fakePolicyLifecycleStore{
|
||||
sanctions: sanctionStore,
|
||||
limits: limitStore,
|
||||
removeSanctionHook: func(input ports.RemoveSanctionInput) error {
|
||||
updated := input.ExpectedActiveRecord
|
||||
removedAt := now.Add(-time.Minute)
|
||||
updated.RemovedAt = &removedAt
|
||||
updated.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
updated.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
if err := sanctionStore.Update(context.Background(), updated); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ports.ErrConflict
|
||||
},
|
||||
}
|
||||
|
||||
service, err := NewRemoveSanctionService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
lifecycle,
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), RemoveSanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodeLoginBlock),
|
||||
ReasonCode: "manual_remove",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result.ActiveSanctions)
|
||||
}
|
||||
|
||||
func TestSetLimitServiceExecuteReplacesActiveLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
current := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-existing"),
|
||||
UserID: userID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
}
|
||||
require.NoError(t, limitStore.Create(context.Background(), current))
|
||||
|
||||
service, err := NewSetLimitService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), SetLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
|
||||
Value: 5,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-2"},
|
||||
AppliedAt: now.Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.ActiveLimits, 1)
|
||||
require.Equal(t, 5, result.ActiveLimits[0].Value)
|
||||
|
||||
storedCurrent, err := limitStore.GetByRecordID(context.Background(), current.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedCurrent.RemovedAt)
|
||||
require.True(t, storedCurrent.RemovedAt.Equal(now))
|
||||
}
|
||||
|
||||
func TestSetLimitServiceExecuteRejectsRetroactiveReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
limitStore := newFakeLimitStore()
|
||||
current := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-existing"),
|
||||
UserID: userID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
}
|
||||
require.NoError(t, limitStore.Create(context.Background(), current))
|
||||
|
||||
service, err := NewSetLimitService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
newFakeSanctionStore(),
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), SetLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
|
||||
Value: 5,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-2"},
|
||||
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestSetLimitServiceExecuteRejectsRetiredLimitCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
tests := []string{
|
||||
string(policy.LimitCodeMaxActivePrivateGames),
|
||||
string(policy.LimitCodeMaxPendingPrivateJoinRequests),
|
||||
string(policy.LimitCodeMaxPendingPrivateInvitesSent),
|
||||
}
|
||||
|
||||
for _, limitCode := range tests {
|
||||
limitCode := limitCode
|
||||
t.Run(limitCode, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewSetLimitService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
newFakeSanctionStore(),
|
||||
newFakeLimitStore(),
|
||||
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), SetLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: limitCode,
|
||||
Value: 5,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-2"},
|
||||
AppliedAt: now.Format(time.RFC3339Nano),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLimitServiceExecuteIgnoresRetiredRecordsDuringReload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
limitStore := newFakeLimitStore()
|
||||
require.NoError(t, limitStore.Create(context.Background(), policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-legacy"),
|
||||
UserID: userID,
|
||||
LimitCode: policy.LimitCodeMaxActivePrivateGames,
|
||||
Value: 9,
|
||||
ReasonCode: common.ReasonCode("legacy_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
}))
|
||||
|
||||
service, err := NewSetLimitService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
newFakeSanctionStore(),
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), SetLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
|
||||
Value: 5,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-2"},
|
||||
AppliedAt: now.Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.ActiveLimits, 1)
|
||||
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.ActiveLimits[0].LimitCode)
|
||||
}
|
||||
|
||||
func TestRemoveLimitServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
sanctionStore := newFakeSanctionStore()
|
||||
limitStore := newFakeLimitStore()
|
||||
|
||||
service, err := NewRemoveLimitService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
limitStore,
|
||||
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), RemoveLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
|
||||
ReasonCode: "manual_remove",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result.ActiveLimits)
|
||||
}
|
||||
|
||||
func TestRemoveLimitServiceExecuteRejectsRetiredLimitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
service, err := NewRemoveLimitService(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
newFakeSanctionStore(),
|
||||
newFakeLimitStore(),
|
||||
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
|
||||
fixedClock{now: now},
|
||||
fixedIDGenerator{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), RemoveLimitInput{
|
||||
UserID: userID.String(),
|
||||
LimitCode: string(policy.LimitCodeMaxPendingPrivateJoinRequests),
|
||||
ReasonCode: "manual_remove",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
existsByUserID map[common.UserID]bool
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
return store.existsByUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSanctionStore struct {
|
||||
byUserID map[common.UserID][]policy.SanctionRecord
|
||||
byRecordID map[policy.SanctionRecordID]policy.SanctionRecord
|
||||
}
|
||||
|
||||
func newFakeSanctionStore() *fakeSanctionStore {
|
||||
return &fakeSanctionStore{
|
||||
byUserID: make(map[common.UserID][]policy.SanctionRecord),
|
||||
byRecordID: make(map[policy.SanctionRecordID]policy.SanctionRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *fakeSanctionStore) Create(_ context.Context, record policy.SanctionRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.byRecordID[record.RecordID]; exists {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
store.byRecordID[record.RecordID] = record
|
||||
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeSanctionStore) GetByRecordID(_ context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
|
||||
record, ok := store.byRecordID[recordID]
|
||||
if !ok {
|
||||
return policy.SanctionRecord{}, ports.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
|
||||
records := store.byUserID[userID]
|
||||
cloned := make([]policy.SanctionRecord, len(records))
|
||||
copy(cloned, records)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (store *fakeSanctionStore) Update(_ context.Context, record policy.SanctionRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.byRecordID[record.RecordID]; !exists {
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
store.byRecordID[record.RecordID] = record
|
||||
records := store.byUserID[record.UserID]
|
||||
for index := range records {
|
||||
if records[index].RecordID == record.RecordID {
|
||||
records[index] = record
|
||||
store.byUserID[record.UserID] = records
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
|
||||
type fakeLimitStore struct {
|
||||
byUserID map[common.UserID][]policy.LimitRecord
|
||||
byRecordID map[policy.LimitRecordID]policy.LimitRecord
|
||||
}
|
||||
|
||||
func newFakeLimitStore() *fakeLimitStore {
|
||||
return &fakeLimitStore{
|
||||
byUserID: make(map[common.UserID][]policy.LimitRecord),
|
||||
byRecordID: make(map[policy.LimitRecordID]policy.LimitRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *fakeLimitStore) Create(_ context.Context, record policy.LimitRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.byRecordID[record.RecordID]; exists {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
store.byRecordID[record.RecordID] = record
|
||||
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeLimitStore) GetByRecordID(_ context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
|
||||
record, ok := store.byRecordID[recordID]
|
||||
if !ok {
|
||||
return policy.LimitRecord{}, ports.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
|
||||
records := store.byUserID[userID]
|
||||
cloned := make([]policy.LimitRecord, len(records))
|
||||
copy(cloned, records)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (store *fakeLimitStore) Update(_ context.Context, record policy.LimitRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.byRecordID[record.RecordID]; !exists {
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
store.byRecordID[record.RecordID] = record
|
||||
records := store.byUserID[record.UserID]
|
||||
for index := range records {
|
||||
if records[index].RecordID == record.RecordID {
|
||||
records[index] = record
|
||||
store.byUserID[record.UserID] = records
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
|
||||
type fakePolicyLifecycleStore struct {
|
||||
sanctions *fakeSanctionStore
|
||||
limits *fakeLimitStore
|
||||
|
||||
applySanctionHook func(input ports.ApplySanctionInput) error
|
||||
removeSanctionHook func(input ports.RemoveSanctionInput) error
|
||||
setLimitHook func(input ports.SetLimitInput) error
|
||||
removeLimitHook func(input ports.RemoveLimitInput) error
|
||||
}
|
||||
|
||||
func (store *fakePolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
|
||||
if store.applySanctionHook != nil {
|
||||
return store.applySanctionHook(input)
|
||||
}
|
||||
|
||||
records, err := store.sanctions.ListByUserID(ctx, input.NewRecord.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
active, err := policy.ActiveSanctionsAt(records, input.NewRecord.AppliedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range active {
|
||||
if record.SanctionCode == input.NewRecord.SanctionCode {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
}
|
||||
|
||||
return store.sanctions.Create(ctx, input.NewRecord)
|
||||
}
|
||||
|
||||
func (store *fakePolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
|
||||
if store.removeSanctionHook != nil {
|
||||
return store.removeSanctionHook(input)
|
||||
}
|
||||
|
||||
return store.sanctions.Update(ctx, input.UpdatedRecord)
|
||||
}
|
||||
|
||||
func (store *fakePolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
|
||||
if store.setLimitHook != nil {
|
||||
return store.setLimitHook(input)
|
||||
}
|
||||
|
||||
if input.ExpectedActiveRecord != nil {
|
||||
if err := store.limits.Update(ctx, *input.UpdatedActiveRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return store.limits.Create(ctx, input.NewRecord)
|
||||
}
|
||||
|
||||
func (store *fakePolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
|
||||
if store.removeLimitHook != nil {
|
||||
return store.removeLimitHook(input)
|
||||
}
|
||||
|
||||
return store.limits.Update(ctx, input.UpdatedRecord)
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type fixedIDGenerator struct {
|
||||
sanctionRecordID policy.SanctionRecordID
|
||||
limitRecordID policy.LimitRecordID
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return generator.sanctionRecordID, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return generator.limitRecordID, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.UserAccountStore = fakeAccountStore{}
|
||||
_ ports.SanctionStore = (*fakeSanctionStore)(nil)
|
||||
_ ports.LimitStore = (*fakeLimitStore)(nil)
|
||||
_ ports.PolicyLifecycleStore = (*fakePolicyLifecycleStore)(nil)
|
||||
_ ports.Clock = fixedClock{}
|
||||
_ ports.IDGenerator = fixedIDGenerator{}
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
package selfservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(validUserAccount())
|
||||
publisher := &recordingSelfServicePublisher{}
|
||||
|
||||
service, err := NewProfileUpdaterWithObservability(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Nova Prime", result.Account.RaceName)
|
||||
require.Len(t, publisher.profileEvents, 1)
|
||||
require.Equal(t, ports.ProfileChangedOperationUpdated, publisher.profileEvents[0].Operation)
|
||||
require.Equal(t, common.Source("gateway_self_service"), publisher.profileEvents[0].Source)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), publisher.profileEvents[0].RaceName)
|
||||
}
|
||||
|
||||
func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(validUserAccount())
|
||||
publisher := &recordingSelfServicePublisher{profileErr: errors.New("publisher unavailable")}
|
||||
|
||||
service, err := NewProfileUpdaterWithObservability(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Nova Prime", result.Account.RaceName)
|
||||
require.Len(t, publisher.profileEvents, 1)
|
||||
|
||||
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), storedAccount.RaceName)
|
||||
}
|
||||
|
||||
func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
UpdatedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
})
|
||||
publisher := &recordingSelfServicePublisher{}
|
||||
|
||||
service, err := NewSettingsUpdaterWithObservability(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
|
||||
UserID: "user-123",
|
||||
PreferredLanguage: "en-us",
|
||||
TimeZone: " UTC ",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "en-US", result.Account.PreferredLanguage)
|
||||
require.Equal(t, "UTC", result.Account.TimeZone)
|
||||
require.Empty(t, publisher.settingsEvents)
|
||||
}
|
||||
|
||||
type recordingSelfServicePublisher struct {
|
||||
profileErr error
|
||||
settingsErr error
|
||||
profileEvents []ports.ProfileChangedEvent
|
||||
settingsEvents []ports.SettingsChangedEvent
|
||||
}
|
||||
|
||||
func (publisher *recordingSelfServicePublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.profileEvents = append(publisher.profileEvents, event)
|
||||
return publisher.profileErr
|
||||
}
|
||||
|
||||
func (publisher *recordingSelfServicePublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.settingsEvents = append(publisher.settingsEvents, event)
|
||||
return publisher.settingsErr
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.ProfileChangedPublisher = (*recordingSelfServicePublisher)(nil)
|
||||
_ ports.SettingsChangedPublisher = (*recordingSelfServicePublisher)(nil)
|
||||
)
|
||||
@@ -0,0 +1,467 @@
|
||||
// Package selfservice implements the authenticated self-service account read
|
||||
// and mutation use cases owned by User Service.
|
||||
package selfservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/accountview"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
const gatewaySelfServiceSource = common.Source("gateway_self_service")
|
||||
|
||||
// ActorRefView stores transport-ready audit actor metadata.
|
||||
type ActorRefView = accountview.ActorRefView
|
||||
|
||||
// EntitlementSnapshotView stores the transport-ready current entitlement
|
||||
// snapshot of one account.
|
||||
type EntitlementSnapshotView = accountview.EntitlementSnapshotView
|
||||
|
||||
// ActiveSanctionView stores one transport-ready active sanction.
|
||||
type ActiveSanctionView = accountview.ActiveSanctionView
|
||||
|
||||
// ActiveLimitView stores one transport-ready active user-specific limit.
|
||||
type ActiveLimitView = accountview.ActiveLimitView
|
||||
|
||||
// AccountView stores the transport-ready authenticated self-service account
|
||||
// aggregate.
|
||||
type AccountView = accountview.AccountView
|
||||
|
||||
// GetMyAccountInput stores one authenticated account-read request.
|
||||
type GetMyAccountInput struct {
|
||||
// UserID stores the authenticated regular-user identifier.
|
||||
UserID string
|
||||
}
|
||||
|
||||
// GetMyAccountResult stores one authenticated account-read result.
|
||||
type GetMyAccountResult struct {
|
||||
// Account stores the read-optimized current account aggregate.
|
||||
Account AccountView `json:"account"`
|
||||
}
|
||||
|
||||
// UpdateMyProfileInput stores one self-service profile mutation request.
|
||||
type UpdateMyProfileInput struct {
|
||||
// UserID stores the authenticated regular-user identifier.
|
||||
UserID string
|
||||
|
||||
// RaceName stores the requested exact replacement race name.
|
||||
RaceName string
|
||||
}
|
||||
|
||||
// UpdateMyProfileResult stores one self-service profile mutation result.
|
||||
type UpdateMyProfileResult struct {
|
||||
// Account stores the refreshed account aggregate after the mutation.
|
||||
Account AccountView `json:"account"`
|
||||
}
|
||||
|
||||
// UpdateMySettingsInput stores one self-service settings mutation request.
|
||||
type UpdateMySettingsInput struct {
|
||||
// UserID stores the authenticated regular-user identifier.
|
||||
UserID string
|
||||
|
||||
// PreferredLanguage stores the requested BCP 47 preferred language.
|
||||
PreferredLanguage string
|
||||
|
||||
// TimeZone stores the requested IANA time-zone name.
|
||||
TimeZone string
|
||||
}
|
||||
|
||||
// UpdateMySettingsResult stores one self-service settings mutation result.
|
||||
type UpdateMySettingsResult struct {
|
||||
// Account stores the refreshed account aggregate after the mutation.
|
||||
Account AccountView `json:"account"`
|
||||
}
|
||||
|
||||
type entitlementReader interface {
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
|
||||
}
|
||||
|
||||
// AccountGetter executes the `GetMyAccount` use case.
|
||||
type AccountGetter struct {
|
||||
loader *accountview.Loader
|
||||
}
|
||||
|
||||
// NewAccountGetter constructs one authenticated account-read use case.
|
||||
func NewAccountGetter(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*AccountGetter, error) {
|
||||
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfservice account getter: %w", err)
|
||||
}
|
||||
|
||||
return &AccountGetter{loader: loader}, nil
|
||||
}
|
||||
|
||||
// Execute reads the current self-service account aggregate of input.UserID.
|
||||
func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInput) (GetMyAccountResult, error) {
|
||||
if ctx == nil {
|
||||
return GetMyAccountResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return GetMyAccountResult{}, err
|
||||
}
|
||||
|
||||
state, err := service.loader.Load(ctx, userID)
|
||||
if err != nil {
|
||||
return GetMyAccountResult{}, err
|
||||
}
|
||||
|
||||
return GetMyAccountResult{Account: state.View()}, nil
|
||||
}
|
||||
|
||||
// ProfileUpdater executes the `UpdateMyProfile` use case.
|
||||
type ProfileUpdater struct {
|
||||
accounts ports.UserAccountStore
|
||||
loader *accountview.Loader
|
||||
policy ports.RaceNamePolicy
|
||||
clock ports.Clock
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
profilePublisher ports.ProfileChangedPublisher
|
||||
}
|
||||
|
||||
// NewProfileUpdater constructs one self-service profile-mutation use case.
|
||||
func NewProfileUpdater(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
policy ports.RaceNamePolicy,
|
||||
) (*ProfileUpdater, error) {
|
||||
return NewProfileUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, policy, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewProfileUpdaterWithObservability constructs one self-service
|
||||
// profile-mutation use case with optional observability hooks.
|
||||
func NewProfileUpdaterWithObservability(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
policy ports.RaceNamePolicy,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
profilePublisher ports.ProfileChangedPublisher,
|
||||
) (*ProfileUpdater, error) {
|
||||
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfservice profile updater: %w", err)
|
||||
}
|
||||
if policy == nil {
|
||||
return nil, fmt.Errorf("selfservice profile updater: race-name policy must not be nil")
|
||||
}
|
||||
|
||||
return &ProfileUpdater{
|
||||
accounts: accounts,
|
||||
loader: loader,
|
||||
policy: policy,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
profilePublisher: profilePublisher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute updates the current self-service profile fields of input.UserID.
|
||||
func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfileInput) (result UpdateMyProfileResult, err error) {
|
||||
outcome := "failed"
|
||||
userIDString := ""
|
||||
defer func() {
|
||||
shared.LogServiceOutcome(service.logger, ctx, "profile update completed", err,
|
||||
"use_case", "update_my_profile",
|
||||
"outcome", outcome,
|
||||
"user_id", userIDString,
|
||||
"source", gatewaySelfServiceSource.String(),
|
||||
)
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
return UpdateMyProfileResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
raceName, err := parseRaceName(input.RaceName)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
|
||||
state, err := service.loader.Load(ctx, userID)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
|
||||
return UpdateMyProfileResult{}, shared.Conflict()
|
||||
}
|
||||
if state.AccountRecord.RaceName == raceName {
|
||||
outcome = "noop"
|
||||
return UpdateMyProfileResult{Account: state.View()}, nil
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC()
|
||||
currentCanonicalKey, err := service.policy.CanonicalKey(state.AccountRecord.RaceName)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, shared.ServiceUnavailable(fmt.Errorf("canonicalize current race name: %w", err))
|
||||
}
|
||||
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := service.accounts.RenameRaceName(ctx, ports.RenameRaceNameInput{
|
||||
UserID: userID,
|
||||
CurrentCanonicalKey: currentCanonicalKey,
|
||||
NewRaceName: raceName,
|
||||
NewReservation: reservation,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
|
||||
service.telemetry.RecordRaceNameReservationConflict(ctx, "update_my_profile")
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return UpdateMyProfileResult{}, shared.SubjectNotFound()
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
return UpdateMyProfileResult{}, shared.Conflict()
|
||||
default:
|
||||
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
updatedState, err := service.loader.Load(ctx, userID)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
outcome = "updated"
|
||||
result = UpdateMyProfileResult{Account: updatedState.View()}
|
||||
service.publishProfileChanged(ctx, updatedState.AccountRecord)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SettingsUpdater executes the `UpdateMySettings` use case.
|
||||
type SettingsUpdater struct {
|
||||
accounts ports.UserAccountStore
|
||||
loader *accountview.Loader
|
||||
clock ports.Clock
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
settingsPublisher ports.SettingsChangedPublisher
|
||||
}
|
||||
|
||||
// NewSettingsUpdater constructs one self-service settings-mutation use case.
|
||||
func NewSettingsUpdater(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*SettingsUpdater, error) {
|
||||
return NewSettingsUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewSettingsUpdaterWithObservability constructs one self-service
|
||||
// settings-mutation use case with optional observability hooks.
|
||||
func NewSettingsUpdaterWithObservability(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
settingsPublisher ports.SettingsChangedPublisher,
|
||||
) (*SettingsUpdater, error) {
|
||||
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfservice settings updater: %w", err)
|
||||
}
|
||||
|
||||
return &SettingsUpdater{
|
||||
accounts: accounts,
|
||||
loader: loader,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
settingsPublisher: settingsPublisher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute updates the current self-service settings fields of input.UserID.
|
||||
func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySettingsInput) (result UpdateMySettingsResult, err error) {
|
||||
outcome := "failed"
|
||||
userIDString := ""
|
||||
defer func() {
|
||||
shared.LogServiceOutcome(service.logger, ctx, "settings update completed", err,
|
||||
"use_case", "update_my_settings",
|
||||
"outcome", outcome,
|
||||
"user_id", userIDString,
|
||||
"source", gatewaySelfServiceSource.String(),
|
||||
)
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
return UpdateMySettingsResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
return UpdateMySettingsResult{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
preferredLanguage, err := parsePreferredLanguage(input.PreferredLanguage)
|
||||
if err != nil {
|
||||
return UpdateMySettingsResult{}, err
|
||||
}
|
||||
timeZone, err := parseTimeZoneName(input.TimeZone)
|
||||
if err != nil {
|
||||
return UpdateMySettingsResult{}, err
|
||||
}
|
||||
|
||||
state, err := service.loader.Load(ctx, userID)
|
||||
if err != nil {
|
||||
return UpdateMySettingsResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
|
||||
return UpdateMySettingsResult{}, shared.Conflict()
|
||||
}
|
||||
if state.AccountRecord.PreferredLanguage == preferredLanguage && state.AccountRecord.TimeZone == timeZone {
|
||||
outcome = "noop"
|
||||
return UpdateMySettingsResult{Account: state.View()}, nil
|
||||
}
|
||||
|
||||
record := state.AccountRecord
|
||||
record.PreferredLanguage = preferredLanguage
|
||||
record.TimeZone = timeZone
|
||||
record.UpdatedAt = service.clock.Now().UTC()
|
||||
|
||||
if err := service.accounts.Update(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return UpdateMySettingsResult{}, shared.SubjectNotFound()
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
return UpdateMySettingsResult{}, shared.Conflict()
|
||||
default:
|
||||
return UpdateMySettingsResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
updatedState, err := service.loader.Load(ctx, userID)
|
||||
if err != nil {
|
||||
return UpdateMySettingsResult{}, err
|
||||
}
|
||||
outcome = "updated"
|
||||
result = UpdateMySettingsResult{Account: updatedState.View()}
|
||||
service.publishSettingsChanged(ctx, updatedState.AccountRecord)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseRaceName(value string) (common.RaceName, error) {
|
||||
return shared.ParseRaceName(value)
|
||||
}
|
||||
|
||||
func parsePreferredLanguage(value string) (common.LanguageTag, error) {
|
||||
languageTag, err := shared.ParseLanguageTag(value)
|
||||
if err != nil {
|
||||
return "", reframeFieldError("preferred_language", "language tag", err)
|
||||
}
|
||||
|
||||
return languageTag, nil
|
||||
}
|
||||
|
||||
func parseTimeZoneName(value string) (common.TimeZoneName, error) {
|
||||
timeZoneName, err := shared.ParseTimeZoneName(value)
|
||||
if err != nil {
|
||||
return "", reframeFieldError("time_zone", "time zone name", err)
|
||||
}
|
||||
|
||||
return timeZoneName, nil
|
||||
}
|
||||
|
||||
func reframeFieldError(fieldName string, valueName string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
prefix := valueName + " "
|
||||
if strings.HasPrefix(message, prefix) {
|
||||
message = fieldName + " " + strings.TrimPrefix(message, prefix)
|
||||
} else {
|
||||
message = fmt.Sprintf("%s: %s", fieldName, message)
|
||||
}
|
||||
|
||||
return shared.InvalidRequest(message)
|
||||
}
|
||||
|
||||
func (service *ProfileUpdater) publishProfileChanged(ctx context.Context, record account.UserAccount) {
|
||||
if service.profilePublisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := ports.ProfileChangedEvent{
|
||||
UserID: record.UserID,
|
||||
OccurredAt: record.UpdatedAt.UTC(),
|
||||
Source: gatewaySelfServiceSource,
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: record.RaceName,
|
||||
}
|
||||
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
|
||||
}
|
||||
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
|
||||
"use_case", "update_my_profile",
|
||||
"user_id", record.UserID.String(),
|
||||
"source", gatewaySelfServiceSource.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *SettingsUpdater) publishSettingsChanged(ctx context.Context, record account.UserAccount) {
|
||||
if service.settingsPublisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := ports.SettingsChangedEvent{
|
||||
UserID: record.UserID,
|
||||
OccurredAt: record.UpdatedAt.UTC(),
|
||||
Source: gatewaySelfServiceSource,
|
||||
Operation: ports.SettingsChangedOperationUpdated,
|
||||
PreferredLanguage: record.PreferredLanguage,
|
||||
TimeZone: record.TimeZone,
|
||||
}
|
||||
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
|
||||
}
|
||||
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
|
||||
"use_case", "update_my_settings",
|
||||
"user_id", record.UserID.String(),
|
||||
"source", gatewaySelfServiceSource.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
package selfservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(validUserAccount())
|
||||
snapshotStore := &fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
}
|
||||
sanctionStore := fakeSanctionStore{
|
||||
byUserID: map[common.UserID][]policy.SanctionRecord{
|
||||
common.UserID("user-123"): {
|
||||
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
|
||||
expiredSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
|
||||
},
|
||||
},
|
||||
}
|
||||
limitStore := fakeLimitStore{
|
||||
byUserID: map[common.UserID][]policy.LimitRecord{
|
||||
common.UserID("user-123"): {
|
||||
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
|
||||
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxActivePrivateGames, 1, now.Add(-2*time.Hour)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service, err := NewAccountGetter(accountStore, snapshotStore, sanctionStore, limitStore, fixedClock{now: now})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: " user-123 "})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-123", result.Account.UserID)
|
||||
require.Equal(t, "DE", result.Account.DeclaredCountry)
|
||||
require.Len(t, result.Account.ActiveSanctions, 1)
|
||||
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.Account.ActiveSanctions[0].SanctionCode)
|
||||
require.Len(t, result.Account.ActiveLimits, 1)
|
||||
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.Account.ActiveLimits[0].LimitCode)
|
||||
}
|
||||
|
||||
func TestAccountGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewAccountGetter(
|
||||
newFakeAccountStore(),
|
||||
&fakeEntitlementSnapshotStore{},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-missing"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestAccountGetterExecuteMissingSnapshotReturnsInternalError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewAccountGetter(
|
||||
newFakeAccountStore(validUserAccount()),
|
||||
&fakeEntitlementSnapshotStore{},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInternalError, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestAccountGetterExecuteRepairsExpiredPaidSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
expiredAt := now.Add(-time.Hour)
|
||||
snapshotStore := &fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): {
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: entitlement.PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: now.Add(-30 * 24 * time.Hour),
|
||||
EndsAt: timePointer(expiredAt),
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
UpdatedAt: expiredAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
reader, err := entitlementsvc.NewReader(
|
||||
snapshotStore,
|
||||
&fakeEntitlementLifecycleStore{snapshotStore: snapshotStore},
|
||||
fixedClock{now: now},
|
||||
readerIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := NewAccountGetter(
|
||||
newFakeAccountStore(validUserAccount()),
|
||||
reader,
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "free", result.Account.Entitlement.PlanCode)
|
||||
require.False(t, result.Account.Entitlement.IsPaid)
|
||||
require.Equal(t, expiredAt, result.Account.Entitlement.StartsAt)
|
||||
}
|
||||
|
||||
func TestProfileUpdaterExecuteBlockedBySanction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(validUserAccount())
|
||||
service, err := NewProfileUpdater(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{
|
||||
byUserID: map[common.UserID][]policy.SanctionRecord{
|
||||
common.UserID("user-123"): {
|
||||
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
|
||||
},
|
||||
},
|
||||
},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
require.Equal(t, 0, accountStore.renameCalls)
|
||||
}
|
||||
|
||||
func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputRaceName string
|
||||
renameErr error
|
||||
wantCode string
|
||||
wantRaceName string
|
||||
wantRenameCalls int
|
||||
wantCurrentKey account.RaceNameCanonicalKey
|
||||
wantNewKey account.RaceNameCanonicalKey
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
inputRaceName: "Nova Prime",
|
||||
wantRaceName: "Nova Prime",
|
||||
wantRenameCalls: 1,
|
||||
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
|
||||
wantNewKey: canonicalKey(common.RaceName("Nova Prime")),
|
||||
},
|
||||
{
|
||||
name: "same canonical different exact",
|
||||
inputRaceName: "P1lot Nova",
|
||||
wantRaceName: "P1lot Nova",
|
||||
wantRenameCalls: 1,
|
||||
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
|
||||
wantNewKey: canonicalKey(common.RaceName("P1lot Nova")),
|
||||
},
|
||||
{
|
||||
name: "no-op",
|
||||
inputRaceName: " Pilot Nova ",
|
||||
wantRaceName: "Pilot Nova",
|
||||
wantRenameCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "conflict",
|
||||
inputRaceName: "Taken Name",
|
||||
renameErr: ports.ErrConflict,
|
||||
wantCode: shared.ErrorCodeConflict,
|
||||
wantRaceName: "Pilot Nova",
|
||||
wantRenameCalls: 1,
|
||||
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
|
||||
wantNewKey: canonicalKey(common.RaceName("Taken Name")),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(validUserAccount())
|
||||
accountStore.renameErr = tt.renameErr
|
||||
service, err := NewProfileUpdater(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: tt.inputRaceName,
|
||||
})
|
||||
if tt.wantCode != "" {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tt.wantCode, shared.CodeOf(err))
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.wantRenameCalls, accountStore.renameCalls)
|
||||
if tt.wantRenameCalls > 0 {
|
||||
require.Equal(t, tt.wantCurrentKey, accountStore.lastRenameInput.CurrentCanonicalKey)
|
||||
require.Equal(t, tt.wantNewKey, accountStore.lastRenameInput.NewReservation.CanonicalKey)
|
||||
}
|
||||
|
||||
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantRaceName, storedAccount.RaceName.String())
|
||||
if tt.wantCode == "" {
|
||||
require.Equal(t, tt.wantRaceName, result.Account.RaceName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUpdaterExecuteBlockedBySanction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(validUserAccount())
|
||||
service, err := NewSettingsUpdater(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{
|
||||
byUserID: map[common.UserID][]policy.SanctionRecord{
|
||||
common.UserID("user-123"): {
|
||||
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
|
||||
},
|
||||
},
|
||||
},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), UpdateMySettingsInput{
|
||||
UserID: "user-123",
|
||||
PreferredLanguage: "en-US",
|
||||
TimeZone: "UTC",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
require.Equal(t, 0, accountStore.updateCalls)
|
||||
}
|
||||
|
||||
func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accountRecord account.UserAccount
|
||||
inputLanguage string
|
||||
inputTimeZone string
|
||||
wantCode string
|
||||
wantLanguage string
|
||||
wantTimeZone string
|
||||
wantUpdateCalls int
|
||||
}{
|
||||
{
|
||||
name: "canonicalized success",
|
||||
accountRecord: validUserAccount(),
|
||||
inputLanguage: " en-us ",
|
||||
inputTimeZone: " UTC ",
|
||||
wantLanguage: "en-US",
|
||||
wantTimeZone: "UTC",
|
||||
wantUpdateCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "no-op",
|
||||
accountRecord: account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
UpdatedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
},
|
||||
inputLanguage: "en-us",
|
||||
inputTimeZone: " UTC ",
|
||||
wantLanguage: "en-US",
|
||||
wantTimeZone: "UTC",
|
||||
wantUpdateCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid preferred language",
|
||||
accountRecord: validUserAccount(),
|
||||
inputLanguage: "bad@@tag",
|
||||
inputTimeZone: "UTC",
|
||||
wantCode: shared.ErrorCodeInvalidRequest,
|
||||
wantLanguage: "en",
|
||||
wantTimeZone: "Europe/Kaliningrad",
|
||||
},
|
||||
{
|
||||
name: "invalid time zone",
|
||||
accountRecord: validUserAccount(),
|
||||
inputLanguage: "en",
|
||||
inputTimeZone: "Mars/Olympus",
|
||||
wantCode: shared.ErrorCodeInvalidRequest,
|
||||
wantLanguage: "en",
|
||||
wantTimeZone: "Europe/Kaliningrad",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAccountStore(tt.accountRecord)
|
||||
service, err := NewSettingsUpdater(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
|
||||
},
|
||||
},
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
|
||||
UserID: "user-123",
|
||||
PreferredLanguage: tt.inputLanguage,
|
||||
TimeZone: tt.inputTimeZone,
|
||||
})
|
||||
if tt.wantCode != "" {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tt.wantCode, shared.CodeOf(err))
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.wantUpdateCalls, accountStore.updateCalls)
|
||||
|
||||
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantLanguage, storedAccount.PreferredLanguage.String())
|
||||
require.Equal(t, tt.wantTimeZone, storedAccount.TimeZone.String())
|
||||
if tt.wantCode == "" {
|
||||
require.Equal(t, tt.wantLanguage, result.Account.PreferredLanguage)
|
||||
require.Equal(t, tt.wantTimeZone, result.Account.TimeZone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
records map[common.UserID]account.UserAccount
|
||||
renameErr error
|
||||
updateErr error
|
||||
renameCalls int
|
||||
updateCalls int
|
||||
lastRenameInput ports.RenameRaceNameInput
|
||||
}
|
||||
|
||||
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
|
||||
byUserID := make(map[common.UserID]account.UserAccount, len(records))
|
||||
for _, record := range records {
|
||||
byUserID[record.UserID] = record
|
||||
}
|
||||
|
||||
return &fakeAccountStore{records: byUserID}
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Create(_ context.Context, input ports.CreateAccountInput) error {
|
||||
if input.Account.Validate() != nil || input.Reservation.Validate() != nil {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
|
||||
record, ok := store.records[userID]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.Email == email {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.RaceName == raceName {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
_, ok := store.records[userID]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) RenameRaceName(_ context.Context, input ports.RenameRaceNameInput) error {
|
||||
store.renameCalls++
|
||||
store.lastRenameInput = input
|
||||
if store.renameErr != nil {
|
||||
return store.renameErr
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record, ok := store.records[input.UserID]
|
||||
if !ok {
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
record.RaceName = input.NewRaceName
|
||||
record.UpdatedAt = input.UpdatedAt.UTC()
|
||||
store.records[input.UserID] = record
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
|
||||
store.updateCalls++
|
||||
if store.updateErr != nil {
|
||||
return store.updateErr
|
||||
}
|
||||
if _, ok := store.records[record.UserID]; !ok {
|
||||
return ports.ErrNotFound
|
||||
}
|
||||
store.records[record.UserID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEntitlementSnapshotStore struct {
|
||||
byUserID map[common.UserID]entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
func (store *fakeEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
|
||||
record, ok := store.byUserID[userID]
|
||||
if !ok {
|
||||
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
|
||||
if store.byUserID != nil {
|
||||
store.byUserID[record.UserID] = record
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEntitlementLifecycleStore struct {
|
||||
snapshotStore *fakeEntitlementSnapshotStore
|
||||
}
|
||||
|
||||
func (store *fakeEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
|
||||
if store.snapshotStore != nil {
|
||||
return store.snapshotStore.Put(ctx, input.NewSnapshot)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type readerIDGenerator struct {
|
||||
recordID entitlement.EntitlementRecordID
|
||||
sanctionRecordID policy.SanctionRecordID
|
||||
limitRecordID policy.LimitRecordID
|
||||
}
|
||||
|
||||
func (generator readerIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator readerIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator readerIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return generator.recordID, nil
|
||||
}
|
||||
|
||||
func (generator readerIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return generator.sanctionRecordID, nil
|
||||
}
|
||||
|
||||
func (generator readerIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return generator.limitRecordID, nil
|
||||
}
|
||||
|
||||
type fakeSanctionStore struct {
|
||||
byUserID map[common.UserID][]policy.SanctionRecord
|
||||
err error
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
|
||||
return policy.SanctionRecord{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
|
||||
if store.err != nil {
|
||||
return nil, store.err
|
||||
}
|
||||
|
||||
records := store.byUserID[userID]
|
||||
cloned := make([]policy.SanctionRecord, len(records))
|
||||
copy(cloned, records)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeLimitStore struct {
|
||||
byUserID map[common.UserID][]policy.LimitRecord
|
||||
err error
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
|
||||
return policy.LimitRecord{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
|
||||
if store.err != nil {
|
||||
return nil, store.err
|
||||
}
|
||||
|
||||
records := store.byUserID[userID]
|
||||
cloned := make([]policy.LimitRecord, len(records))
|
||||
copy(cloned, records)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type stubRaceNamePolicy struct{}
|
||||
|
||||
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
return canonicalKey(raceName), nil
|
||||
}
|
||||
|
||||
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
||||
return account.RaceNameCanonicalKey(strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
).Replace(strings.ToLower(raceName.String())))
|
||||
}
|
||||
|
||||
func validUserAccount() account.UserAccount {
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: now.Add(-time.Hour),
|
||||
Source: common.Source("auth_registration"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func validActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
|
||||
return policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
|
||||
UserID: userID,
|
||||
SanctionCode: code,
|
||||
Scope: common.Scope("self_service"),
|
||||
ReasonCode: common.ReasonCode("policy_enforced"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
AppliedAt: appliedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func expiredSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
|
||||
expiresAt := appliedAt.Add(30 * time.Minute)
|
||||
record := validActiveSanction(userID, code, appliedAt)
|
||||
record.RecordID = policy.SanctionRecordID(record.RecordID.String() + "-expired")
|
||||
record.ExpiresAt = &expiresAt
|
||||
return record
|
||||
}
|
||||
|
||||
func validActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
|
||||
return policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-" + string(code)),
|
||||
UserID: userID,
|
||||
LimitCode: code,
|
||||
Value: value,
|
||||
ReasonCode: common.ReasonCode("policy_enforced"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
AppliedAt: appliedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func removedLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
|
||||
removedAt := appliedAt.Add(30 * time.Minute)
|
||||
record := validActiveLimit(userID, code, value, appliedAt)
|
||||
record.RecordID = policy.LimitRecordID(record.RecordID.String() + "-removed")
|
||||
record.RemovedAt = &removedAt
|
||||
record.RemovedBy = common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}
|
||||
record.RemovedReasonCode = common.ReasonCode("policy_reset")
|
||||
return record
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
utcValue := value.UTC()
|
||||
return &utcValue
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
|
||||
_ ports.EntitlementSnapshotStore = (*fakeEntitlementSnapshotStore)(nil)
|
||||
_ ports.EntitlementLifecycleStore = (*fakeEntitlementLifecycleStore)(nil)
|
||||
_ ports.SanctionStore = fakeSanctionStore{}
|
||||
_ ports.LimitStore = fakeLimitStore{}
|
||||
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
|
||||
_ ports.IDGenerator = readerIDGenerator{}
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package shared provides shared request parsing and error normalization used
|
||||
// by the user-service application and transport layers.
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeInvalidRequest reports malformed or semantically invalid caller
|
||||
// input.
|
||||
ErrorCodeInvalidRequest = "invalid_request"
|
||||
|
||||
// ErrorCodeConflict reports that the requested mutation conflicts with the
|
||||
// current source-of-truth state.
|
||||
ErrorCodeConflict = "conflict"
|
||||
|
||||
// ErrorCodeSubjectNotFound reports that the requested user subject does not
|
||||
// exist.
|
||||
ErrorCodeSubjectNotFound = "subject_not_found"
|
||||
|
||||
// ErrorCodeServiceUnavailable reports that a required dependency is
|
||||
// temporarily unavailable.
|
||||
ErrorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// ErrorCodeInternalError reports that a local invariant failed unexpectedly.
|
||||
ErrorCodeInternalError = "internal_error"
|
||||
)
|
||||
|
||||
var internalErrorStatusCodes = map[string]int{
|
||||
ErrorCodeInvalidRequest: http.StatusBadRequest,
|
||||
ErrorCodeConflict: http.StatusConflict,
|
||||
ErrorCodeSubjectNotFound: http.StatusNotFound,
|
||||
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
ErrorCodeInternalError: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
var internalStableMessages = map[string]string{
|
||||
ErrorCodeConflict: "request conflicts with current state",
|
||||
ErrorCodeSubjectNotFound: "subject not found",
|
||||
ErrorCodeServiceUnavailable: "service is unavailable",
|
||||
ErrorCodeInternalError: "internal server error",
|
||||
}
|
||||
|
||||
// InternalErrorProjection stores the transport-ready representation of one
|
||||
// normalized trusted-internal error.
|
||||
type InternalErrorProjection struct {
|
||||
// StatusCode stores the HTTP status returned to the trusted caller.
|
||||
StatusCode int
|
||||
|
||||
// Code stores the stable machine-readable error code written into the JSON
|
||||
// envelope.
|
||||
Code string
|
||||
|
||||
// Message stores the stable or caller-safe message written into the JSON
|
||||
// envelope.
|
||||
Message string
|
||||
}
|
||||
|
||||
// ServiceError stores one normalized application-layer failure.
|
||||
type ServiceError struct {
|
||||
// Code stores the stable machine-readable error code.
|
||||
Code string
|
||||
|
||||
// Message stores the caller-safe error message.
|
||||
Message string
|
||||
|
||||
// Err stores the wrapped underlying cause when one exists.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the caller-safe message of ServiceError.
|
||||
func (err *ServiceError) Error() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(err.Message) != "" {
|
||||
return err.Message
|
||||
}
|
||||
if strings.TrimSpace(err.Code) != "" {
|
||||
return err.Code
|
||||
}
|
||||
if err.Err != nil {
|
||||
return err.Err.Error()
|
||||
}
|
||||
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped underlying cause.
|
||||
func (err *ServiceError) Unwrap() error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// NewServiceError returns one new normalized application-layer error.
|
||||
func NewServiceError(code string, message string, err error) *ServiceError {
|
||||
return &ServiceError{
|
||||
Code: strings.TrimSpace(code),
|
||||
Message: strings.TrimSpace(message),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidRequest returns one normalized invalid-request error.
|
||||
func InvalidRequest(message string) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInvalidRequest, strings.TrimSpace(message), nil)
|
||||
}
|
||||
|
||||
// Conflict returns one normalized conflict error.
|
||||
func Conflict() *ServiceError {
|
||||
return NewServiceError(ErrorCodeConflict, "", nil)
|
||||
}
|
||||
|
||||
// SubjectNotFound returns one normalized subject-not-found error.
|
||||
func SubjectNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSubjectNotFound, "", nil)
|
||||
}
|
||||
|
||||
// ServiceUnavailable returns one normalized dependency-unavailable error.
|
||||
func ServiceUnavailable(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeServiceUnavailable, "", err)
|
||||
}
|
||||
|
||||
// InternalError returns one normalized invariant-failure error.
|
||||
func InternalError(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInternalError, "", err)
|
||||
}
|
||||
|
||||
// CodeOf returns the normalized service error code carried by err when one is
|
||||
// available.
|
||||
func CodeOf(err error) string {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
if !ok || serviceErr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return serviceErr.Code
|
||||
}
|
||||
|
||||
// ProjectInternalError normalizes err to the frozen trusted-internal HTTP
|
||||
// error surface.
|
||||
func ProjectInternalError(err error) InternalErrorProjection {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
code := CodeOf(err)
|
||||
if _, exists := internalErrorStatusCodes[code]; !exists {
|
||||
return InternalErrorProjection{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: internalStableMessages[ErrorCodeInternalError],
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
if ok && serviceErr != nil {
|
||||
message = serviceErr.Message
|
||||
}
|
||||
if stable, exists := internalStableMessages[code]; exists {
|
||||
message = stable
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = internalStableMessages[ErrorCodeInternalError]
|
||||
}
|
||||
|
||||
return InternalErrorProjection{
|
||||
StatusCode: internalErrorStatusCodes[code],
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// NormalizeString trims surrounding Unicode whitespace from value.
|
||||
func NormalizeString(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// ParseEmail trims value and validates it as one exact normalized e-mail
|
||||
// subject used by the auth-facing contract.
|
||||
func ParseEmail(value string) (common.Email, error) {
|
||||
email := common.Email(NormalizeString(value))
|
||||
if err := email.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// ParseUserID trims value and validates it as one stable user identifier.
|
||||
func ParseUserID(value string) (common.UserID, error) {
|
||||
userID := common.UserID(NormalizeString(value))
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ParseRaceName trims value and validates it as one exact stored race name.
|
||||
func ParseRaceName(value string) (common.RaceName, error) {
|
||||
raceName := common.RaceName(NormalizeString(value))
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return raceName, nil
|
||||
}
|
||||
|
||||
// ParseReasonCode trims value and validates it as one machine-readable reason
|
||||
// code.
|
||||
func ParseReasonCode(value string) (common.ReasonCode, error) {
|
||||
reasonCode := common.ReasonCode(NormalizeString(value))
|
||||
if err := reasonCode.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return reasonCode, nil
|
||||
}
|
||||
|
||||
// ParseLanguageTag trims value and validates it against the current Stage 03
|
||||
// boundary and BCP 47 semantics, returning the canonical tag form.
|
||||
func ParseLanguageTag(value string) (common.LanguageTag, error) {
|
||||
languageTag := common.LanguageTag(NormalizeString(value))
|
||||
if err := languageTag.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
parsedTag, err := language.Parse(languageTag.String())
|
||||
if err != nil {
|
||||
return "", InvalidRequest("language tag must be a valid BCP 47 language tag")
|
||||
}
|
||||
|
||||
canonicalTag := common.LanguageTag(parsedTag.String())
|
||||
if err := canonicalTag.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return canonicalTag, nil
|
||||
}
|
||||
|
||||
// ParseTimeZoneName trims value and validates it against the current Stage 03
|
||||
// boundary and IANA time-zone semantics.
|
||||
func ParseTimeZoneName(value string) (common.TimeZoneName, error) {
|
||||
timeZoneName := common.TimeZoneName(NormalizeString(value))
|
||||
if err := timeZoneName.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
if _, err := time.LoadLocation(timeZoneName.String()); err != nil {
|
||||
return "", InvalidRequest("time zone name must be a valid IANA time zone name")
|
||||
}
|
||||
|
||||
return timeZoneName, nil
|
||||
}
|
||||
|
||||
// ParseRegistrationPreferredLanguage trims value, validates it as one create-
|
||||
// only BCP 47 registration language tag, and returns the canonical tag form.
|
||||
func ParseRegistrationPreferredLanguage(value string) (common.LanguageTag, error) {
|
||||
languageTag, err := ParseLanguageTag(value)
|
||||
if err != nil {
|
||||
return "", reframeFieldError("registration_context.preferred_language", "language tag", err)
|
||||
}
|
||||
|
||||
return languageTag, nil
|
||||
}
|
||||
|
||||
// ParseRegistrationTimeZoneName trims value and validates it as one create-
|
||||
// only IANA registration time-zone name.
|
||||
func ParseRegistrationTimeZoneName(value string) (common.TimeZoneName, error) {
|
||||
timeZoneName, err := ParseTimeZoneName(value)
|
||||
if err != nil {
|
||||
return "", reframeFieldError("registration_context.time_zone", "time zone name", err)
|
||||
}
|
||||
|
||||
return timeZoneName, nil
|
||||
}
|
||||
|
||||
func reframeFieldError(fieldName string, valueName string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
prefix := valueName + " "
|
||||
if strings.HasPrefix(message, prefix) {
|
||||
message = fieldName + " " + strings.TrimPrefix(message, prefix)
|
||||
} else {
|
||||
message = fmt.Sprintf("%s: %s", fieldName, message)
|
||||
}
|
||||
|
||||
return InvalidRequest(message)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseLanguageTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErrCode string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "canonicalizes valid tag",
|
||||
input: " en-us ",
|
||||
want: "en-US",
|
||||
},
|
||||
{
|
||||
name: "rejects invalid tag",
|
||||
input: "en-@",
|
||||
wantErrCode: ErrorCodeInvalidRequest,
|
||||
wantErr: "language tag must be a valid BCP 47 language tag",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseLanguageTag(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Empty(t, got)
|
||||
require.Equal(t, tt.wantErrCode, CodeOf(err))
|
||||
require.Equal(t, tt.wantErr, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeZoneName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErrCode string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "accepts valid zone",
|
||||
input: " Europe/Kaliningrad ",
|
||||
want: "Europe/Kaliningrad",
|
||||
},
|
||||
{
|
||||
name: "rejects invalid zone",
|
||||
input: "Mars/Olympus",
|
||||
wantErrCode: ErrorCodeInvalidRequest,
|
||||
wantErr: "time zone name must be a valid IANA time zone name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseTimeZoneName(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Empty(t, got)
|
||||
require.Equal(t, tt.wantErrCode, CodeOf(err))
|
||||
require.Equal(t, tt.wantErr, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegistrationPreferredLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseRegistrationPreferredLanguage(" en-us ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "en-US", got.String())
|
||||
|
||||
_, err = ParseRegistrationPreferredLanguage("bad@@tag")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
|
||||
require.Equal(t, "registration_context.preferred_language must be a valid BCP 47 language tag", err.Error())
|
||||
}
|
||||
|
||||
func TestParseRegistrationTimeZoneName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseRegistrationTimeZoneName(" Europe/Kaliningrad ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Europe/Kaliningrad", got.String())
|
||||
|
||||
_, err = ParseRegistrationTimeZoneName("Mars/Olympus")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
|
||||
require.Equal(t, "registration_context.time_zone must be a valid IANA time zone name", err.Error())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"galaxy/user/internal/logging"
|
||||
)
|
||||
|
||||
// LogServiceOutcome writes one structured service-level outcome log with a
|
||||
// stable severity derived from err and with trace fields attached when ctx
|
||||
// carries an active span.
|
||||
func LogServiceOutcome(logger *slog.Logger, ctx context.Context, message string, err error, attrs ...any) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
logger.InfoContext(ctx, message, attrs...)
|
||||
case isExpectedServiceErrorCode(CodeOf(err)):
|
||||
logger.WarnContext(ctx, message, append(attrs, "error", err.Error())...)
|
||||
default:
|
||||
logger.ErrorContext(ctx, message, append(attrs, "error", err.Error())...)
|
||||
}
|
||||
}
|
||||
|
||||
// MetricOutcome returns the stable low-cardinality outcome label derived from
|
||||
// err for service metrics.
|
||||
func MetricOutcome(err error) string {
|
||||
if err == nil {
|
||||
return "success"
|
||||
}
|
||||
|
||||
code := CodeOf(err)
|
||||
if code == "" {
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// LogEventPublicationFailure writes one structured error log for an auxiliary
|
||||
// post-commit event publication failure.
|
||||
func LogEventPublicationFailure(logger *slog.Logger, ctx context.Context, eventType string, err error, attrs ...any) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
attrs = append(attrs,
|
||||
"event_type", eventType,
|
||||
"error", err.Error(),
|
||||
)
|
||||
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
|
||||
|
||||
logger.ErrorContext(ctx, "auxiliary event publication failed", attrs...)
|
||||
}
|
||||
|
||||
func isExpectedServiceErrorCode(code string) bool {
|
||||
switch code {
|
||||
case ErrorCodeInvalidRequest,
|
||||
ErrorCodeConflict,
|
||||
ErrorCodeSubjectNotFound:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
)
|
||||
|
||||
// BuildRaceNameReservation constructs one validated race-name reservation
|
||||
// record for userID and raceName at reservedAt.
|
||||
func BuildRaceNameReservation(
|
||||
policy ports.RaceNamePolicy,
|
||||
userID common.UserID,
|
||||
raceName common.RaceName,
|
||||
reservedAt time.Time,
|
||||
) (account.RaceNameReservation, error) {
|
||||
if policy == nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: race-name policy must not be nil")
|
||||
}
|
||||
if err := userID.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("build race-name reservation reserved at", reservedAt); err != nil {
|
||||
return account.RaceNameReservation{}, err
|
||||
}
|
||||
|
||||
canonicalKey, err := policy.CanonicalKey(raceName)
|
||||
if err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
record := account.RaceNameReservation{
|
||||
CanonicalKey: canonicalKey,
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
// Package telemetry provides shared OpenTelemetry runtime helpers and
|
||||
// low-cardinality user-service instruments.
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
|
||||
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
|
||||
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const meterName = "galaxy/user"
|
||||
|
||||
const (
|
||||
defaultServiceName = "galaxy-user"
|
||||
|
||||
processExporterNone = "none"
|
||||
processExporterOTLP = "otlp"
|
||||
processProtocolHTTPProtobuf = "http/protobuf"
|
||||
processProtocolGRPC = "grpc"
|
||||
)
|
||||
|
||||
// ProcessConfig configures the process-wide OpenTelemetry runtime.
|
||||
type ProcessConfig struct {
|
||||
// ServiceName overrides the default OpenTelemetry service name.
|
||||
ServiceName string
|
||||
|
||||
// TracesExporter selects the external traces exporter. Supported values are
|
||||
// `none` and `otlp`.
|
||||
TracesExporter string
|
||||
|
||||
// MetricsExporter selects the external metrics exporter. Supported values
|
||||
// are `none` and `otlp`.
|
||||
MetricsExporter string
|
||||
|
||||
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
|
||||
// `otlp`.
|
||||
TracesProtocol string
|
||||
|
||||
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
|
||||
// `otlp`.
|
||||
MetricsProtocol string
|
||||
|
||||
// StdoutTracesEnabled enables the additional stdout trace exporter used for
|
||||
// local development and debugging.
|
||||
StdoutTracesEnabled bool
|
||||
|
||||
// StdoutMetricsEnabled enables the additional stdout metric exporter used
|
||||
// for local development and debugging.
|
||||
StdoutMetricsEnabled bool
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a supported OpenTelemetry exporter
|
||||
// configuration.
|
||||
func (cfg ProcessConfig) Validate() error {
|
||||
switch cfg.TracesExporter {
|
||||
case processExporterNone, processExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter)
|
||||
}
|
||||
|
||||
switch cfg.MetricsExporter {
|
||||
case processExporterNone, processExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter)
|
||||
}
|
||||
|
||||
if cfg.TracesProtocol != "" && cfg.TracesProtocol != processProtocolHTTPProtobuf && cfg.TracesProtocol != processProtocolGRPC {
|
||||
return fmt.Errorf("unsupported OTLP traces protocol %q", cfg.TracesProtocol)
|
||||
}
|
||||
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != processProtocolHTTPProtobuf && cfg.MetricsProtocol != processProtocolGRPC {
|
||||
return fmt.Errorf("unsupported OTLP metrics protocol %q", cfg.MetricsProtocol)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Runtime owns the user-service OpenTelemetry providers, the Prometheus
|
||||
// metrics handler, and the custom low-cardinality instruments.
|
||||
type Runtime struct {
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
meterProvider metric.MeterProvider
|
||||
promHandler http.Handler
|
||||
|
||||
shutdownMu sync.Mutex
|
||||
shutdownDone bool
|
||||
shutdownErr error
|
||||
shutdownFns []func(context.Context) error
|
||||
|
||||
internalHTTPRequests metric.Int64Counter
|
||||
internalHTTPDuration metric.Float64Histogram
|
||||
authResolutionOutcomes metric.Int64Counter
|
||||
userCreationOutcomes metric.Int64Counter
|
||||
raceNameReservationConflicts metric.Int64Counter
|
||||
entitlementMutations metric.Int64Counter
|
||||
sanctionMutations metric.Int64Counter
|
||||
limitMutations metric.Int64Counter
|
||||
eventPublicationFailures metric.Int64Counter
|
||||
}
|
||||
|
||||
// New constructs a lightweight telemetry runtime around meterProvider for
|
||||
// tests and embedded use cases that do not need process-level exporter wiring.
|
||||
func New(meterProvider metric.MeterProvider) (*Runtime, error) {
|
||||
return NewWithProviders(meterProvider, nil)
|
||||
}
|
||||
|
||||
// NewWithProviders constructs a telemetry runtime around explicitly supplied
|
||||
// meterProvider and tracerProvider values.
|
||||
func NewWithProviders(meterProvider metric.MeterProvider, tracerProvider oteltrace.TracerProvider) (*Runtime, error) {
|
||||
if meterProvider == nil {
|
||||
meterProvider = otel.GetMeterProvider()
|
||||
}
|
||||
if tracerProvider == nil {
|
||||
tracerProvider = otel.GetTracerProvider()
|
||||
}
|
||||
if meterProvider == nil {
|
||||
return nil, errors.New("new user telemetry runtime: nil meter provider")
|
||||
}
|
||||
if tracerProvider == nil {
|
||||
return nil, errors.New("new user telemetry runtime: nil tracer provider")
|
||||
}
|
||||
|
||||
return buildRuntime(meterProvider, tracerProvider, http.NotFoundHandler(), nil)
|
||||
}
|
||||
|
||||
// NewProcess constructs the process-wide user-service OpenTelemetry runtime
|
||||
// from cfg, installs the resulting providers globally, and returns the
|
||||
// runtime.
|
||||
func NewProcess(ctx context.Context, cfg ProcessConfig, logger *slog.Logger) (*Runtime, error) {
|
||||
return newProcess(ctx, cfg, logger, os.Stdout, os.Stdout)
|
||||
}
|
||||
|
||||
// TracerProvider returns the runtime tracer provider.
|
||||
func (r *Runtime) TracerProvider() oteltrace.TracerProvider {
|
||||
if r == nil || r.tracerProvider == nil {
|
||||
return otel.GetTracerProvider()
|
||||
}
|
||||
|
||||
return r.tracerProvider
|
||||
}
|
||||
|
||||
// MeterProvider returns the runtime meter provider.
|
||||
func (r *Runtime) MeterProvider() metric.MeterProvider {
|
||||
if r == nil || r.meterProvider == nil {
|
||||
return otel.GetMeterProvider()
|
||||
}
|
||||
|
||||
return r.meterProvider
|
||||
}
|
||||
|
||||
// Handler returns the Prometheus handler that should be mounted on the admin
|
||||
// listener.
|
||||
func (r *Runtime) Handler() http.Handler {
|
||||
if r == nil || r.promHandler == nil {
|
||||
return http.NotFoundHandler()
|
||||
}
|
||||
|
||||
return r.promHandler
|
||||
}
|
||||
|
||||
// Shutdown flushes and stops the configured telemetry providers. Shutdown is
|
||||
// idempotent.
|
||||
func (r *Runtime) Shutdown(ctx context.Context) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.shutdownMu.Lock()
|
||||
if r.shutdownDone {
|
||||
err := r.shutdownErr
|
||||
r.shutdownMu.Unlock()
|
||||
return err
|
||||
}
|
||||
r.shutdownDone = true
|
||||
r.shutdownMu.Unlock()
|
||||
|
||||
var shutdownErr error
|
||||
for index := len(r.shutdownFns) - 1; index >= 0; index-- {
|
||||
shutdownErr = errors.Join(shutdownErr, r.shutdownFns[index](ctx))
|
||||
}
|
||||
|
||||
r.shutdownMu.Lock()
|
||||
r.shutdownErr = shutdownErr
|
||||
r.shutdownMu.Unlock()
|
||||
|
||||
return shutdownErr
|
||||
}
|
||||
|
||||
// RecordInternalHTTPRequest records one internal HTTP request outcome.
|
||||
func (r *Runtime) RecordInternalHTTPRequest(ctx context.Context, attrs []attribute.KeyValue, duration time.Duration) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
options := metric.WithAttributes(attrs...)
|
||||
r.internalHTTPRequests.Add(normalizeContext(ctx), 1, options)
|
||||
r.internalHTTPDuration.Record(normalizeContext(ctx), duration.Seconds()*1000, options)
|
||||
}
|
||||
|
||||
// RecordAuthResolutionOutcome records one auth-facing resolution outcome.
|
||||
func (r *Runtime) RecordAuthResolutionOutcome(ctx context.Context, operation string, outcome string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.authResolutionOutcomes.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(
|
||||
attribute.String("operation", strings.TrimSpace(operation)),
|
||||
attribute.String("outcome", strings.TrimSpace(outcome)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordUserCreationOutcome records one ensure-by-email coarse outcome.
|
||||
func (r *Runtime) RecordUserCreationOutcome(ctx context.Context, outcome string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.userCreationOutcomes.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("outcome", strings.TrimSpace(outcome))),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordRaceNameReservationConflict records one race-name reservation conflict
|
||||
// for operation.
|
||||
func (r *Runtime) RecordRaceNameReservationConflict(ctx context.Context, operation string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.raceNameReservationConflicts.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("operation", strings.TrimSpace(operation))),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordEntitlementMutation records one entitlement command outcome.
|
||||
func (r *Runtime) RecordEntitlementMutation(ctx context.Context, command string, outcome string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.entitlementMutations.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(
|
||||
attribute.String("command", strings.TrimSpace(command)),
|
||||
attribute.String("outcome", strings.TrimSpace(outcome)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordSanctionMutation records one sanction command outcome.
|
||||
func (r *Runtime) RecordSanctionMutation(ctx context.Context, command string, outcome string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.sanctionMutations.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(
|
||||
attribute.String("command", strings.TrimSpace(command)),
|
||||
attribute.String("outcome", strings.TrimSpace(outcome)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordLimitMutation records one limit command outcome.
|
||||
func (r *Runtime) RecordLimitMutation(ctx context.Context, command string, outcome string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.limitMutations.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(
|
||||
attribute.String("command", strings.TrimSpace(command)),
|
||||
attribute.String("outcome", strings.TrimSpace(outcome)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordEventPublicationFailure records one post-commit auxiliary event
|
||||
// publication failure.
|
||||
func (r *Runtime) RecordEventPublicationFailure(ctx context.Context, eventType string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.eventPublicationFailures.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("event_type", strings.TrimSpace(eventType))),
|
||||
)
|
||||
}
|
||||
|
||||
func newProcess(ctx context.Context, cfg ProcessConfig, logger *slog.Logger, stdoutTraceWriter io.Writer, stdoutMetricWriter io.Writer) (*Runtime, error) {
|
||||
if ctx == nil {
|
||||
return nil, errors.New("new user telemetry process: nil context")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new user telemetry process: %w", err)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if strings.TrimSpace(cfg.ServiceName) == "" {
|
||||
cfg.ServiceName = defaultServiceName
|
||||
}
|
||||
|
||||
res, err := resource.New(
|
||||
ctx,
|
||||
resource.WithAttributes(attribute.String("service.name", cfg.ServiceName)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user telemetry process: resource: %w", err)
|
||||
}
|
||||
|
||||
tracerProvider, err := newTracerProvider(ctx, res, cfg, stdoutTraceWriter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user telemetry process: tracer provider: %w", err)
|
||||
}
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
prometheusExporter, err := otelprom.New(otelprom.WithRegisterer(registry))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user telemetry process: prometheus exporter: %w", err)
|
||||
}
|
||||
|
||||
meterProvider, err := newMeterProvider(ctx, res, cfg, prometheusExporter, stdoutMetricWriter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user telemetry process: meter provider: %w", err)
|
||||
}
|
||||
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
runtime, err := buildRuntime(
|
||||
meterProvider,
|
||||
tracerProvider,
|
||||
promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||
[]func(context.Context) error{
|
||||
meterProvider.Shutdown,
|
||||
tracerProvider.Shutdown,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user telemetry process: %w", err)
|
||||
}
|
||||
|
||||
logger.InfoContext(ctx, "user telemetry configured",
|
||||
"service_name", cfg.ServiceName,
|
||||
"traces_exporter", cfg.TracesExporter,
|
||||
"metrics_exporter", cfg.MetricsExporter,
|
||||
"stdout_traces_enabled", cfg.StdoutTracesEnabled,
|
||||
"stdout_metrics_enabled", cfg.StdoutMetricsEnabled,
|
||||
)
|
||||
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
func buildRuntime(
|
||||
meterProvider metric.MeterProvider,
|
||||
tracerProvider oteltrace.TracerProvider,
|
||||
promHandler http.Handler,
|
||||
shutdownFns []func(context.Context) error,
|
||||
) (*Runtime, error) {
|
||||
meter := meterProvider.Meter(meterName)
|
||||
|
||||
internalHTTPRequests, err := meter.Int64Counter("user.internal_http.requests")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: internal_http.requests: %w", err)
|
||||
}
|
||||
internalHTTPDuration, err := meter.Float64Histogram("user.internal_http.duration", metric.WithUnit("ms"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: internal_http.duration: %w", err)
|
||||
}
|
||||
authResolutionOutcomes, err := meter.Int64Counter("user.auth_resolution.outcomes")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: auth_resolution.outcomes: %w", err)
|
||||
}
|
||||
userCreationOutcomes, err := meter.Int64Counter("user.user_creation.outcomes")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: user_creation.outcomes: %w", err)
|
||||
}
|
||||
raceNameReservationConflicts, err := meter.Int64Counter("user.race_name.reservation_conflicts")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: race_name.reservation_conflicts: %w", err)
|
||||
}
|
||||
entitlementMutations, err := meter.Int64Counter("user.entitlement.mutations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: entitlement.mutations: %w", err)
|
||||
}
|
||||
sanctionMutations, err := meter.Int64Counter("user.sanction.mutations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: sanction.mutations: %w", err)
|
||||
}
|
||||
limitMutations, err := meter.Int64Counter("user.limit.mutations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: limit.mutations: %w", err)
|
||||
}
|
||||
eventPublicationFailures, err := meter.Int64Counter("user.event_publication_failures")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: event_publication_failures: %w", err)
|
||||
}
|
||||
|
||||
if promHandler == nil {
|
||||
promHandler = http.NotFoundHandler()
|
||||
}
|
||||
|
||||
return &Runtime{
|
||||
tracerProvider: tracerProvider,
|
||||
meterProvider: meterProvider,
|
||||
promHandler: promHandler,
|
||||
shutdownFns: shutdownFns,
|
||||
internalHTTPRequests: internalHTTPRequests,
|
||||
internalHTTPDuration: internalHTTPDuration,
|
||||
authResolutionOutcomes: authResolutionOutcomes,
|
||||
userCreationOutcomes: userCreationOutcomes,
|
||||
raceNameReservationConflicts: raceNameReservationConflicts,
|
||||
entitlementMutations: entitlementMutations,
|
||||
sanctionMutations: sanctionMutations,
|
||||
limitMutations: limitMutations,
|
||||
eventPublicationFailures: eventPublicationFailures,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newTracerProvider(ctx context.Context, res *resource.Resource, cfg ProcessConfig, stdoutWriter io.Writer) (*sdktrace.TracerProvider, error) {
|
||||
options := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)}
|
||||
|
||||
if cfg.TracesExporter == processExporterOTLP {
|
||||
exporter, err := newOTLPTraceExporter(ctx, cfg.TracesProtocol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, sdktrace.WithBatcher(exporter))
|
||||
}
|
||||
if cfg.StdoutTracesEnabled {
|
||||
exporter, err := stdouttrace.New(
|
||||
stdouttrace.WithPrettyPrint(),
|
||||
stdouttrace.WithWriter(stdoutWriter),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, sdktrace.WithBatcher(exporter))
|
||||
}
|
||||
|
||||
return sdktrace.NewTracerProvider(options...), nil
|
||||
}
|
||||
|
||||
func newMeterProvider(
|
||||
ctx context.Context,
|
||||
res *resource.Resource,
|
||||
cfg ProcessConfig,
|
||||
prometheusExporter sdkmetric.Reader,
|
||||
stdoutWriter io.Writer,
|
||||
) (*sdkmetric.MeterProvider, error) {
|
||||
options := []sdkmetric.Option{
|
||||
sdkmetric.WithResource(res),
|
||||
sdkmetric.WithReader(prometheusExporter),
|
||||
}
|
||||
|
||||
if cfg.MetricsExporter == processExporterOTLP {
|
||||
exporter, err := newOTLPMetricExporter(ctx, cfg.MetricsProtocol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
|
||||
}
|
||||
if cfg.StdoutMetricsEnabled {
|
||||
exporter, err := stdoutmetric.New(
|
||||
stdoutmetric.WithPrettyPrint(),
|
||||
stdoutmetric.WithWriter(stdoutWriter),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
|
||||
}
|
||||
|
||||
return sdkmetric.NewMeterProvider(options...), nil
|
||||
}
|
||||
|
||||
func newOTLPTraceExporter(ctx context.Context, protocol string) (sdktrace.SpanExporter, error) {
|
||||
switch protocol {
|
||||
case "", processProtocolHTTPProtobuf:
|
||||
return otlptracehttp.New(ctx)
|
||||
case processProtocolGRPC:
|
||||
return otlptracegrpc.New(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported OTLP traces protocol %q", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func newOTLPMetricExporter(ctx context.Context, protocol string) (sdkmetric.Exporter, error) {
|
||||
switch protocol {
|
||||
case "", processProtocolHTTPProtobuf:
|
||||
return otlpmetrichttp.New(ctx)
|
||||
case processProtocolGRPC:
|
||||
return otlpmetricgrpc.New(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported OTLP metrics protocol %q", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
func TestNewProcessBuildsWithoutExporters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, err := newProcess(context.Background(), ProcessConfig{
|
||||
ServiceName: "galaxy-user-test",
|
||||
TracesExporter: processExporterNone,
|
||||
MetricsExporter: processExporterNone,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)), io.Discard, io.Discard)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, runtime.TracerProvider())
|
||||
assert.NotNil(t, runtime.MeterProvider())
|
||||
assert.NotNil(t, runtime.Handler())
|
||||
require.NoError(t, runtime.Shutdown(context.Background()))
|
||||
require.NoError(t, runtime.Shutdown(context.Background()))
|
||||
}
|
||||
|
||||
func TestNewProcessBuildsWithStdoutExporters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
traceBuffer := &bytes.Buffer{}
|
||||
metricBuffer := &bytes.Buffer{}
|
||||
|
||||
runtime, err := newProcess(context.Background(), ProcessConfig{
|
||||
ServiceName: "galaxy-user-test",
|
||||
TracesExporter: processExporterNone,
|
||||
MetricsExporter: processExporterNone,
|
||||
StdoutTracesEnabled: true,
|
||||
StdoutMetricsEnabled: true,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)), traceBuffer, metricBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, span := runtime.TracerProvider().Tracer("test").Start(context.Background(), "internal-request")
|
||||
runtime.RecordUserCreationOutcome(ctx, "created")
|
||||
span.End()
|
||||
|
||||
require.NoError(t, runtime.Shutdown(context.Background()))
|
||||
assert.NotEmpty(t, traceBuffer.String())
|
||||
assert.NotEmpty(t, metricBuffer.String())
|
||||
}
|
||||
|
||||
func TestNewPreservesBusinessMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
tracerProvider := sdktrace.NewTracerProvider()
|
||||
|
||||
runtime, err := NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
runtime.RecordInternalHTTPRequest(context.Background(), []attribute.KeyValue{
|
||||
attribute.String("route", "/api/v1/internal/users/:user_id/exists"),
|
||||
attribute.String("method", "GET"),
|
||||
attribute.String("edge_outcome", "success"),
|
||||
}, 125*time.Millisecond)
|
||||
runtime.RecordAuthResolutionOutcome(context.Background(), "resolve_by_email", "existing")
|
||||
runtime.RecordUserCreationOutcome(context.Background(), "created")
|
||||
runtime.RecordRaceNameReservationConflict(context.Background(), "update_my_profile")
|
||||
runtime.RecordEntitlementMutation(context.Background(), "grant", "success")
|
||||
runtime.RecordSanctionMutation(context.Background(), "apply", "conflict")
|
||||
runtime.RecordLimitMutation(context.Background(), "remove", "subject_not_found")
|
||||
runtime.RecordEventPublicationFailure(context.Background(), "user.profile.changed")
|
||||
|
||||
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
|
||||
"route": "/api/v1/internal/users/:user_id/exists",
|
||||
"method": "GET",
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
assertHistogramCount(t, reader, "user.internal_http.duration", map[string]string{
|
||||
"route": "/api/v1/internal/users/:user_id/exists",
|
||||
"method": "GET",
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.auth_resolution.outcomes", map[string]string{
|
||||
"operation": "resolve_by_email",
|
||||
"outcome": "existing",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
|
||||
"outcome": "created",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.race_name.reservation_conflicts", map[string]string{
|
||||
"operation": "update_my_profile",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.entitlement.mutations", map[string]string{
|
||||
"command": "grant",
|
||||
"outcome": "success",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.sanction.mutations", map[string]string{
|
||||
"command": "apply",
|
||||
"outcome": "conflict",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.limit.mutations", map[string]string{
|
||||
"command": "remove",
|
||||
"outcome": "subject_not_found",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
|
||||
"event_type": "user.profile.changed",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func assertHistogramCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantCount uint64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
histogram, ok := metric.Data.(metricdata.Histogram[float64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range histogram.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantCount, point.Count)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "histogram %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user