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)
|
||||
}
|
||||
Reference in New Issue
Block a user