feat: user service
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user