feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
552
internal/app/usernames/service.go
Normal file
552
internal/app/usernames/service.go
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
// Package usernames implements the collectible (Fragment-style) username
|
||||
// registry use cases: reading a peer's username vector, toggling and reordering
|
||||
// the collectible rows a client owns, and the operator lifecycle that mints,
|
||||
// transfers, revokes and burns the assets behind those rows.
|
||||
//
|
||||
// The service owns normalisation and validation. Every entry point normalises
|
||||
// the name through domain.NormalizeUsername and runs the domain Validate()
|
||||
// checks before the store is touched, so an RPC handler, the admin API and a
|
||||
// unit test all reject the same shapes with the same errors.
|
||||
package usernames
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultListLimit is the admin listing page size used when the caller does
|
||||
// not bound the query itself.
|
||||
defaultListLimit = 50
|
||||
// maxListLimit bounds one listing page regardless of the requested limit.
|
||||
maxListLimit = 200
|
||||
// defaultTransferLimit / maxTransferLimit bound the provenance log page.
|
||||
defaultTransferLimit = 50
|
||||
maxTransferLimit = 200
|
||||
// usernamePlaceholder is the substitution supported by the operator URL
|
||||
// template, e.g. https://example.org/nft/{username}.
|
||||
usernamePlaceholder = "{username}"
|
||||
// defaultCollectibleURLPath is the public-link route used when no operator
|
||||
// template is configured.
|
||||
defaultCollectibleURLPath = "nft/username"
|
||||
)
|
||||
|
||||
// ErrPeerInvalid rejects a registry mutation for a peer that cannot hold
|
||||
// usernames. Only users and channels have a username registry; anything else is
|
||||
// a caller bug rather than a client-visible protocol state.
|
||||
var ErrPeerInvalid = errors.New("username peer invalid")
|
||||
|
||||
// PeerUsernameNotifier is the domain-only edge hook invoked after a username
|
||||
// registry mutation. The RPC router implements it: it invalidates the cached
|
||||
// peer projections and pushes the username change to online clients, exactly
|
||||
// like the account.updateUsername path does for the editable slot. Keeping it an
|
||||
// injected port means this package never depends on the protocol edge.
|
||||
type PeerUsernameNotifier interface {
|
||||
NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error
|
||||
}
|
||||
|
||||
// Service is the collectible username use-case layer.
|
||||
type Service struct {
|
||||
registry store.UsernameRegistryStore
|
||||
collectibles store.CollectibleUsernameStore
|
||||
notifier PeerUsernameNotifier
|
||||
|
||||
// urlTemplate is the operator-provided collectible landing URL template;
|
||||
// publicBaseURL is the fallback root the default route is built from.
|
||||
urlTemplate string
|
||||
publicBaseURL string
|
||||
|
||||
now func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Option adjusts optional service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithRegistryStore injects the peer username registry reader/writer.
|
||||
func WithRegistryStore(registry store.UsernameRegistryStore) Option {
|
||||
return func(s *Service) { s.registry = registry }
|
||||
}
|
||||
|
||||
// WithCollectibleStore injects the collectible asset lifecycle store.
|
||||
func WithCollectibleStore(collectibles store.CollectibleUsernameStore) Option {
|
||||
return func(s *Service) { s.collectibles = collectibles }
|
||||
}
|
||||
|
||||
// WithNotifier injects the edge invalidation/update hook.
|
||||
func WithNotifier(notifier PeerUsernameNotifier) Option {
|
||||
return func(s *Service) { s.notifier = notifier }
|
||||
}
|
||||
|
||||
// WithURLTemplate configures the collectible asset landing URL template. An
|
||||
// empty template keeps the public-link default route.
|
||||
func WithURLTemplate(template string) Option {
|
||||
return func(s *Service) { s.urlTemplate = strings.TrimSpace(template) }
|
||||
}
|
||||
|
||||
// WithPublicBaseURL configures the public-link root the default collectible URL
|
||||
// route is derived from.
|
||||
func WithPublicBaseURL(baseURL string) Option {
|
||||
return func(s *Service) { s.publicBaseURL = strings.TrimSpace(baseURL) }
|
||||
}
|
||||
|
||||
// WithClock injects the clock (tests).
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger injects the service logger.
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return func(s *Service) {
|
||||
if log != nil {
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates the collectible username service. Every dependency is
|
||||
// optional: a service without stores answers with a configuration error instead
|
||||
// of panicking, which keeps partial deployments diagnosable.
|
||||
func NewService(opts ...Option) *Service {
|
||||
s := &Service{now: time.Now, log: zap.NewNop()}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetPeerUsernameNotifier injects the edge hook after construction. The RPC
|
||||
// router is built after the app services, so the notification port is bound
|
||||
// here rather than through NewService.
|
||||
func (s *Service) SetPeerUsernameNotifier(notifier PeerUsernameNotifier) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.notifier = notifier
|
||||
}
|
||||
|
||||
// Configured reports whether both registries are installed.
|
||||
func (s *Service) Configured() bool {
|
||||
return s != nil && s.registry != nil && s.collectibles != nil
|
||||
}
|
||||
|
||||
func (s *Service) registryStore() (store.UsernameRegistryStore, error) {
|
||||
if s == nil || s.registry == nil {
|
||||
return nil, fmt.Errorf("username registry store is not configured")
|
||||
}
|
||||
return s.registry, nil
|
||||
}
|
||||
|
||||
func (s *Service) collectibleStore() (store.CollectibleUsernameStore, error) {
|
||||
if s == nil || s.collectibles == nil {
|
||||
return nil, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
return s.collectibles, nil
|
||||
}
|
||||
|
||||
// PeerUsernames returns the peer's username vector in projection order.
|
||||
func (s *Service) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return nil, nil
|
||||
}
|
||||
list, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return domain.SortUsernames(list), nil
|
||||
}
|
||||
|
||||
// UsernamesBatch resolves several peers in one round trip. Peers holding no
|
||||
// usernames are absent from the result.
|
||||
func (s *Service) UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unique := make([]domain.Peer, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if !validPeer(peer) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
unique = append(unique, peer)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return map[domain.Peer][]domain.Username{}, nil
|
||||
}
|
||||
batch, err := registry.PeerUsernamesBatch(ctx, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[domain.Peer][]domain.Username, len(batch))
|
||||
for peer, list := range batch {
|
||||
if len(list) == 0 {
|
||||
continue
|
||||
}
|
||||
out[peer] = domain.SortUsernames(list)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ToggleUsername activates or deactivates one collectible row. The editable
|
||||
// slot is never touched: it is owned by account/channels.updateUsername.
|
||||
func (s *Service) ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if username == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
current, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := registry.SetUsernameActive(ctx, peer, username, active)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// ReorderUsernames rewrites the collectible order. order must be a permutation
|
||||
// of the peer's collectible usernames; the editable slot always projects first.
|
||||
func (s *Service) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
normalized := make([]string, 0, len(order))
|
||||
for _, name := range order {
|
||||
normalized = append(normalized, domain.NormalizeUsername(name))
|
||||
}
|
||||
current, err := registry.PeerUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := domain.ValidateUsernameReorder(current, normalized); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed, err := registry.ReorderUsernames(ctx, peer, normalized)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// DeactivateAllUsernames clears the active flag on every collectible row.
|
||||
func (s *Service) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
|
||||
registry, err := s.registryStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !validPeer(peer) {
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
changed, err := registry.DeactivateAllUsernames(ctx, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, peer)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// CollectibleInfo returns the fragment.collectibleInfo projection for a name.
|
||||
func (s *Service) CollectibleInfo(ctx context.Context, username string) (domain.CollectibleInfo, error) {
|
||||
asset, err := s.Collectible(ctx, username)
|
||||
if err != nil {
|
||||
return domain.CollectibleInfo{}, err
|
||||
}
|
||||
return asset.Info(), nil
|
||||
}
|
||||
|
||||
// Collectible looks up the asset behind a collectible username.
|
||||
func (s *Service) Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, err
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if !domain.ValidCollectibleUsername(username) {
|
||||
return domain.CollectibleUsername{}, domain.ErrUsernameInvalid
|
||||
}
|
||||
return collectibles.CollectibleUsername(ctx, username)
|
||||
}
|
||||
|
||||
// Mint creates a collectible asset, optionally assigning it in the same
|
||||
// command. An empty URL is rendered from the configured template and an unset
|
||||
// purchase date is stamped with the service clock, so the stored provenance is
|
||||
// always complete and reproducible.
|
||||
func (s *Service) Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if strings.TrimSpace(req.URL) == "" {
|
||||
req.URL = s.CollectibleURL(req.Username)
|
||||
}
|
||||
if req.PurchaseDate.IsZero() {
|
||||
req.PurchaseDate = s.now().UTC()
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
asset, created, err := collectibles.MintCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if created {
|
||||
s.notifyPeers(ctx, req.Owner, asset.Owner)
|
||||
}
|
||||
return asset, created, nil
|
||||
}
|
||||
|
||||
// Transfer moves the asset to req.To, either out of the vault or from the
|
||||
// current holder. Both the previous and the new holder are invalidated.
|
||||
func (s *Service) Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
asset, changed, err := collectibles.TransferCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, previousOwner, req.To, asset.Owner)
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// Revoke returns the asset to the vault, or burns it when req.Burn is set.
|
||||
func (s *Service) Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
asset, changed, err := collectibles.RevokeCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
if changed {
|
||||
s.notifyPeers(ctx, previousOwner, asset.Owner)
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// Delete removes an asset outright, releasing its name and discarding its
|
||||
// provenance. Revoke with Burn retires an asset but keeps the history; this is
|
||||
// the operator's escape hatch for an asset issued by mistake.
|
||||
//
|
||||
// The previous owner is notified exactly like a revoke: the peer's projection
|
||||
// still carries the username until it is invalidated.
|
||||
func (s *Service) Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
|
||||
deleted, err := collectibles.DeleteCollectibleUsername(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if deleted {
|
||||
s.notifyPeers(ctx, previousOwner, domain.Peer{})
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// List is the admin listing query. The limit is always bounded, so an
|
||||
// unfiltered operator request can never ask the store for an unbounded scan.
|
||||
func (s *Service) List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Status != "" && !filter.Status.Valid() {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if filter.Owner.Type != "" && !validPeer(filter.Owner) {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
filter.Query = domain.NormalizeUsername(filter.Query)
|
||||
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
||||
return collectibles.ListCollectibleUsernames(ctx, filter)
|
||||
}
|
||||
|
||||
// Transfers returns the provenance log of one asset, newest first.
|
||||
func (s *Service) Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
collectibles, err := s.collectibleStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if collectibleID <= 0 {
|
||||
return nil, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return collectibles.CollectibleUsernameTransfers(ctx, collectibleID, clampLimit(limit, defaultTransferLimit, maxTransferLimit))
|
||||
}
|
||||
|
||||
// CollectibleURL renders the asset landing URL for a name. The operator
|
||||
// template wins; {username} is substituted when present and appended as a path
|
||||
// segment when it is not. Without a template the public-link default route is
|
||||
// used, and without any configured root the URL stays empty rather than
|
||||
// pointing at an unrelated host.
|
||||
func (s *Service) CollectibleURL(username string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
template := strings.TrimSpace(s.urlTemplate)
|
||||
if template != "" {
|
||||
if strings.Contains(template, usernamePlaceholder) {
|
||||
return strings.ReplaceAll(template, usernamePlaceholder, username)
|
||||
}
|
||||
return strings.TrimRight(template, "/") + "/" + username
|
||||
}
|
||||
if strings.TrimSpace(s.publicBaseURL) == "" {
|
||||
return ""
|
||||
}
|
||||
return links.Build(s.publicBaseURL, defaultCollectibleURLPath+"/"+username, nil)
|
||||
}
|
||||
|
||||
// currentOwner reads the holder before a lifecycle mutation so the previous
|
||||
// peer's projection is invalidated too. It is best effort: a missing or
|
||||
// unreadable asset only means there is no extra peer to notify, and the
|
||||
// mutation itself remains the authority.
|
||||
func (s *Service) currentOwner(ctx context.Context, collectibles store.CollectibleUsernameStore, username string) domain.Peer {
|
||||
asset, err := collectibles.CollectibleUsername(ctx, username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
s.log.Debug("read collectible username owner before mutation",
|
||||
zap.String("username", username),
|
||||
zap.Error(err))
|
||||
}
|
||||
return domain.Peer{}
|
||||
}
|
||||
if !asset.Owned() {
|
||||
return domain.Peer{}
|
||||
}
|
||||
return asset.Owner
|
||||
}
|
||||
|
||||
// notifyPeers invalidates projections and pushes updates for every distinct
|
||||
// affected peer. Notification is best effort: the registry mutation already
|
||||
// committed, and a failed push converges through the client's next
|
||||
// authoritative peer read.
|
||||
func (s *Service) notifyPeers(ctx context.Context, peers ...domain.Peer) {
|
||||
if s == nil || s.notifier == nil {
|
||||
return
|
||||
}
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if !validPeer(peer) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
if err := s.notifier.NotifyPeerUsernamesChanged(ctx, peer); err != nil {
|
||||
s.log.Warn("notify collectible username change failed",
|
||||
zap.String("peer_type", string(peer.Type)),
|
||||
zap.Int64("peer_id", peer.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validPeer(peer domain.Peer) bool {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return peer.ID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clampLimit(limit, fallback, maximum int) int {
|
||||
if limit <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if limit > maximum {
|
||||
return maximum
|
||||
}
|
||||
return limit
|
||||
}
|
||||
734
internal/app/usernames/service_test.go
Normal file
734
internal/app/usernames/service_test.go
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
package usernames
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
testUser = domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
testChannel = domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
|
||||
testClock = time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
type toggleCall struct {
|
||||
peer domain.Peer
|
||||
username string
|
||||
active bool
|
||||
}
|
||||
|
||||
// fakeRegistry is an in-memory domain.Username registry recording exactly what
|
||||
// the service asked it to do, so the tests can assert normalisation reached the
|
||||
// store and validation did not.
|
||||
type fakeRegistry struct {
|
||||
lists map[domain.Peer][]domain.Username
|
||||
toggles []toggleCall
|
||||
orders [][]string
|
||||
clears []domain.Peer
|
||||
changed bool
|
||||
batchErr error
|
||||
}
|
||||
|
||||
func newFakeRegistry() *fakeRegistry {
|
||||
return &fakeRegistry{lists: map[domain.Peer][]domain.Username{}, changed: true}
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
|
||||
if f.batchErr != nil {
|
||||
return nil, f.batchErr
|
||||
}
|
||||
return append([]domain.Username(nil), f.lists[peer]...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
if f.batchErr != nil {
|
||||
return nil, f.batchErr
|
||||
}
|
||||
out := make(map[domain.Peer][]domain.Username, len(peers))
|
||||
for _, peer := range peers {
|
||||
if list, ok := f.lists[peer]; ok {
|
||||
out[peer] = append([]domain.Username(nil), list...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
||||
f.toggles = append(f.toggles, toggleCall{peer: peer, username: username, active: active})
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) ReorderUsernames(_ context.Context, _ domain.Peer, order []string) (bool, error) {
|
||||
f.orders = append(f.orders, append([]string(nil), order...))
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
|
||||
f.clears = append(f.clears, peer)
|
||||
return f.changed, nil
|
||||
}
|
||||
|
||||
// fakeCollectibles records the lifecycle commands and serves stored assets.
|
||||
type fakeCollectibles struct {
|
||||
assets map[string]domain.CollectibleUsername
|
||||
mints []domain.MintCollectibleUsernameRequest
|
||||
transfers []domain.TransferCollectibleUsernameRequest
|
||||
revokes []domain.RevokeCollectibleUsernameRequest
|
||||
deletes []domain.DeleteCollectibleUsernameRequest
|
||||
filters []domain.CollectibleUsernameFilter
|
||||
logLimits []int
|
||||
created bool
|
||||
changed bool
|
||||
}
|
||||
|
||||
func newFakeCollectibles() *fakeCollectibles {
|
||||
return &fakeCollectibles{assets: map[string]domain.CollectibleUsername{}, created: true, changed: true}
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.mints = append(f.mints, req)
|
||||
asset := domain.CollectibleUsername{
|
||||
ID: int64(len(f.mints)), Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
|
||||
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount, URL: req.URL,
|
||||
}
|
||||
if req.Owner.Type != "" {
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.Owner
|
||||
asset.OriginalOwner = req.Owner
|
||||
}
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.created, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.transfers = append(f.transfers, req)
|
||||
asset := f.assets[strings.ToLower(req.Username)]
|
||||
asset.Username = req.Username
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.To
|
||||
asset.TransferCount++
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.revokes = append(f.revokes, req)
|
||||
asset := f.assets[strings.ToLower(req.Username)]
|
||||
asset.Username = req.Username
|
||||
asset.Owner = domain.Peer{}
|
||||
asset.Status = domain.CollectibleUsernameStatusVault
|
||||
if req.Burn {
|
||||
asset.Status = domain.CollectibleUsernameStatusBurned
|
||||
}
|
||||
f.assets[strings.ToLower(req.Username)] = asset
|
||||
return asset, f.changed, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
f.deletes = append(f.deletes, req)
|
||||
key := strings.ToLower(req.Username)
|
||||
if _, ok := f.assets[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.assets, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
asset, ok := f.assets[strings.ToLower(username)]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
|
||||
for _, asset := range f.assets {
|
||||
if asset.ID == id {
|
||||
return asset, nil
|
||||
}
|
||||
}
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
f.filters = append(f.filters, filter)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibles) CollectibleUsernameTransfers(_ context.Context, _ int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
f.logLimits = append(f.logLimits, limit)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// recordingNotifier captures the peers whose projections were invalidated.
|
||||
type recordingNotifier struct {
|
||||
peers []domain.Peer
|
||||
err error
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) NotifyPeerUsernamesChanged(_ context.Context, peer domain.Peer) error {
|
||||
n.peers = append(n.peers, peer)
|
||||
return n.err
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, registry *fakeRegistry, collectibles *fakeCollectibles, opts ...Option) (*Service, *recordingNotifier) {
|
||||
t.Helper()
|
||||
notifier := &recordingNotifier{}
|
||||
base := []Option{
|
||||
WithRegistryStore(registry),
|
||||
WithCollectibleStore(collectibles),
|
||||
WithNotifier(notifier),
|
||||
WithClock(func() time.Time { return testClock }),
|
||||
}
|
||||
return NewService(append(base, opts...)...), notifier
|
||||
}
|
||||
|
||||
func TestPeerUsernamesProjectsStoredOrder(t *testing.T) {
|
||||
// Legacy numbering: the editable slot and the first collectible both carry
|
||||
// sort_order 0, and the editable slot wins that tie, so a peer that never
|
||||
// reordered anything projects its own username first.
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "editable", Active: true, Editable: true, SortOrder: 0},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if got := projectedNames(t, service); got != "editable,alpha,zeta" {
|
||||
t.Fatalf("projection order = %v, want editable,alpha,zeta", got)
|
||||
}
|
||||
|
||||
// After a reorder that made a collectible primary, stored order decides and
|
||||
// the editable slot is no longer first: clients show usernames[0] as primary.
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "zeta", Active: true, SortOrder: 2, CollectibleID: 2},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "editable", Active: true, Editable: true, SortOrder: 1},
|
||||
}
|
||||
if got := projectedNames(t, service); got != "alpha,editable,zeta" {
|
||||
t.Fatalf("reordered projection = %v, want alpha,editable,zeta", got)
|
||||
}
|
||||
}
|
||||
|
||||
func projectedNames(t *testing.T, service *Service) string {
|
||||
t.Helper()
|
||||
list, err := service.PeerUsernames(context.Background(), testUser)
|
||||
if err != nil {
|
||||
t.Fatalf("PeerUsernames: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
got = append(got, item.Username)
|
||||
}
|
||||
return strings.Join(got, ",")
|
||||
}
|
||||
|
||||
func TestUsernamesBatchSkipsInvalidAndEmptyPeers(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}}
|
||||
registry.lists[testChannel] = nil
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
batch, err := service.UsernamesBatch(context.Background(), []domain.Peer{testUser, testUser, testChannel, {}, {Type: domain.PeerTypeUser}})
|
||||
if err != nil {
|
||||
t.Fatalf("UsernamesBatch: %v", err)
|
||||
}
|
||||
if len(batch) != 1 || len(batch[testUser]) != 1 {
|
||||
t.Fatalf("batch = %#v, want only the peer holding usernames", batch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleUsernameNormalizesBeforeStore(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "Nft_One", Active: false, CollectibleID: 1},
|
||||
}
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.ToggleUsername(context.Background(), testUser, " @Nft_One ", true)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ToggleUsername = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.toggles) != 1 || registry.toggles[0].username != "Nft_One" || !registry.toggles[0].active {
|
||||
t.Fatalf("store toggles = %#v, want normalized Nft_One", registry.toggles)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the toggled peer", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleUsernameValidatesBeforeStore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
list []domain.Username
|
||||
username string
|
||||
active bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "editable slot is not collectible",
|
||||
list: []domain.Username{{Username: "editable", Active: true, Editable: true}},
|
||||
username: "editable",
|
||||
wantErr: domain.ErrUsernameNotCollectible,
|
||||
},
|
||||
{
|
||||
name: "unknown username",
|
||||
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
|
||||
username: "missing",
|
||||
wantErr: domain.ErrUsernameNotOccupied,
|
||||
},
|
||||
{
|
||||
name: "empty username",
|
||||
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
|
||||
username: "@",
|
||||
wantErr: domain.ErrUsernameInvalid,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = test.list
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
_, err := service.ToggleUsername(context.Background(), testUser, test.username, test.active)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("ToggleUsername error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if len(registry.toggles) != 0 {
|
||||
t.Fatalf("store was called with invalid input: %#v", registry.toggles)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("notifier ran for a rejected toggle: %#v", notifier.peers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderUsernamesNormalizesPermutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
|
||||
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
|
||||
}
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.ReorderUsernames(context.Background(), testUser, []string{"@zeta", " alpha ", "editable"})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ReorderUsernames = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "zeta,alpha,editable" {
|
||||
t.Fatalf("store order = %#v, want normalized zeta,alpha,editable", registry.orders)
|
||||
}
|
||||
if len(notifier.peers) != 1 {
|
||||
t.Fatalf("notified peers = %#v, want one", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderUsernamesRejectsIncompletePermutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "alpha", Active: true, CollectibleID: 1},
|
||||
{Username: "zeta", Active: true, CollectibleID: 2},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if _, err := service.ReorderUsernames(context.Background(), testUser, []string{"alpha"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("ReorderUsernames error = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
if len(registry.orders) != 0 {
|
||||
t.Fatalf("store was called with a non-permutation: %#v", registry.orders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReorderUsernamesAcceptsTheEditableSlot is the report "channels.reorderUsernames
|
||||
// answers USERNAME_INVALID": Telegram Desktop sends the whole visible list, and
|
||||
// core.telegram.org/api/fragment requires exactly that ("all currently active
|
||||
// usernames must be specified"), so the editable slot is a legitimate member of
|
||||
// the order -- including as its first entry, and including when it is the only
|
||||
// username the peer has.
|
||||
func TestReorderUsernamesAcceptsTheEditableSlot(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testChannel] = []domain.Username{
|
||||
{Username: "chan_slot", Active: true, Editable: true},
|
||||
}
|
||||
service, _ := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
|
||||
t.Fatalf("editable-only reorder: %v", err)
|
||||
}
|
||||
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "chan_slot" {
|
||||
t.Fatalf("store order = %#v, want chan_slot", registry.orders)
|
||||
}
|
||||
|
||||
// An inactive collectible does not have to be listed, and listing an unknown
|
||||
// name is still rejected.
|
||||
registry.lists[testChannel] = []domain.Username{
|
||||
{Username: "chan_slot", Active: true, Editable: true},
|
||||
{Username: "hidden", Active: false, CollectibleID: 7},
|
||||
}
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
|
||||
t.Fatalf("reorder omitting an inactive collectible: %v", err)
|
||||
}
|
||||
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot", "nothere"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("reorder with an unknown name = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateAllUsernamesNotifiesPeer(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
service, notifier := newTestService(t, registry, newFakeCollectibles())
|
||||
|
||||
changed, err := service.DeactivateAllUsernames(context.Background(), testChannel)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("DeactivateAllUsernames = %v, %v", changed, err)
|
||||
}
|
||||
if len(registry.clears) != 1 || registry.clears[0] != testChannel {
|
||||
t.Fatalf("store clears = %#v", registry.clears)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testChannel {
|
||||
t.Fatalf("notified peers = %#v", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintRendersCollectibleURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
url string
|
||||
wantURL string
|
||||
username string
|
||||
}{
|
||||
{
|
||||
name: "public-link default route",
|
||||
opts: []Option{WithPublicBaseURL("https://example.test")},
|
||||
username: "alpha",
|
||||
wantURL: "https://example.test/nft/username/alpha",
|
||||
},
|
||||
{
|
||||
name: "template placeholder",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/{username}?ref=1"), WithPublicBaseURL("https://example.test")},
|
||||
username: "alpha",
|
||||
wantURL: "https://frag.example/u/alpha?ref=1",
|
||||
},
|
||||
{
|
||||
name: "template without placeholder appends the name",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/")},
|
||||
username: "alpha",
|
||||
wantURL: "https://frag.example/u/alpha",
|
||||
},
|
||||
{
|
||||
name: "explicit request URL wins",
|
||||
opts: []Option{WithURLTemplate("https://frag.example/u/{username}")},
|
||||
username: "alpha",
|
||||
url: "https://operator.example/custom",
|
||||
wantURL: "https://operator.example/custom",
|
||||
},
|
||||
{
|
||||
name: "no template and no base URL keeps the URL empty",
|
||||
username: "alpha",
|
||||
wantURL: "",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles, test.opts...)
|
||||
|
||||
asset, created, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "@" + test.username, Currency: domain.CollectibleCurrencyStars, Amount: 1000, URL: test.url,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("Mint = %v, %v", created, err)
|
||||
}
|
||||
if asset.URL != test.wantURL {
|
||||
t.Fatalf("asset URL = %q, want %q", asset.URL, test.wantURL)
|
||||
}
|
||||
if len(collectibles.mints) != 1 {
|
||||
t.Fatalf("mints = %d, want 1", len(collectibles.mints))
|
||||
}
|
||||
if got := collectibles.mints[0].Username; got != test.username {
|
||||
t.Fatalf("stored username = %q, want normalized %q", got, test.username)
|
||||
}
|
||||
if !collectibles.mints[0].PurchaseDate.Equal(testClock) {
|
||||
t.Fatalf("purchase date = %v, want the service clock %v", collectibles.mints[0].PurchaseDate, testClock)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintValidatesBeforeStore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req domain.MintCollectibleUsernameRequest
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "username too short",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "ab", Currency: domain.CollectibleCurrencyStars},
|
||||
wantErr: domain.ErrUsernameInvalid,
|
||||
},
|
||||
{
|
||||
name: "unsupported currency",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: "EUR"},
|
||||
wantErr: domain.ErrCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "crypto amount without currency",
|
||||
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: domain.CollectibleCurrencyStars, CryptoAmount: 5},
|
||||
wantErr: domain.ErrCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "owner peer is not a username holder",
|
||||
req: domain.MintCollectibleUsernameRequest{
|
||||
Username: "alpha", Currency: domain.CollectibleCurrencyStars,
|
||||
Owner: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
|
||||
},
|
||||
wantErr: domain.ErrCollectibleUsernameStateInvalid,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), test.req); !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Mint error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if len(collectibles.mints) != 0 {
|
||||
t.Fatalf("store was called with invalid input: %#v", collectibles.mints)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("notifier ran for a rejected mint: %#v", notifier.peers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintNotifiesOwnerOnly(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles, WithPublicBaseURL("https://example.test"))
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "vaulted", Currency: domain.CollectibleCurrencyStars,
|
||||
}); err != nil {
|
||||
t.Fatalf("Mint vault: %v", err)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("vault mint notified %#v, want nothing", notifier.peers)
|
||||
}
|
||||
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
|
||||
Username: "assigned", Currency: domain.CollectibleCurrencyStars, Owner: testUser,
|
||||
}); err != nil {
|
||||
t.Fatalf("Mint assigned: %v", err)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the assigned owner", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferNotifiesPreviousAndNewOwner(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
}
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
_, changed, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{
|
||||
Username: "@Alpha", To: testChannel, Actor: "admin", CommandKey: "cmd-1",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("Transfer = %v, %v", changed, err)
|
||||
}
|
||||
if len(collectibles.transfers) != 1 || collectibles.transfers[0].Username != "Alpha" {
|
||||
t.Fatalf("stored transfer = %#v, want normalized username", collectibles.transfers)
|
||||
}
|
||||
if len(notifier.peers) != 2 {
|
||||
t.Fatalf("notified peers = %#v, want previous and new owner", notifier.peers)
|
||||
}
|
||||
seen := map[domain.Peer]bool{notifier.peers[0]: true, notifier.peers[1]: true}
|
||||
if !seen[testUser] || !seen[testChannel] {
|
||||
t.Fatalf("notified peers = %#v, want %v and %v", notifier.peers, testUser, testChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeNotifiesPreviousOwner(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
}
|
||||
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
asset, changed, err := service.Revoke(context.Background(), domain.RevokeCollectibleUsernameRequest{
|
||||
Username: "alpha", Burn: true, Actor: "admin",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("Revoke = %v, %v", changed, err)
|
||||
}
|
||||
if asset.Status != domain.CollectibleUsernameStatusBurned {
|
||||
t.Fatalf("asset status = %q, want burned", asset.Status)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
|
||||
t.Fatalf("notified peers = %#v, want the previous owner", notifier.peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleInfoProjectsPurchaseRecord(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
collectibles.assets["alpha"] = domain.CollectibleUsername{
|
||||
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
|
||||
PurchaseDate: testClock, Currency: domain.CollectibleCurrencyStars, Amount: 2500,
|
||||
URL: "https://example.test/nft/username/alpha",
|
||||
}
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
info, err := service.CollectibleInfo(context.Background(), "@ALPHA")
|
||||
if err != nil {
|
||||
t.Fatalf("CollectibleInfo: %v", err)
|
||||
}
|
||||
if info.PurchaseDate != int(testClock.Unix()) || info.Amount != 2500 || info.Currency != domain.CollectibleCurrencyStars {
|
||||
t.Fatalf("collectible info = %#v", info)
|
||||
}
|
||||
if _, err := service.CollectibleInfo(context.Background(), "ab"); !errors.Is(err, domain.ErrUsernameInvalid) {
|
||||
t.Fatalf("CollectibleInfo short name error = %v, want ErrUsernameInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndTransfersBoundThePage(t *testing.T) {
|
||||
collectibles := newFakeCollectibles()
|
||||
service, _ := newTestService(t, newFakeRegistry(), collectibles)
|
||||
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Query: " @Alpha ", Limit: 0}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Limit: 100000}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(collectibles.filters) != 2 ||
|
||||
collectibles.filters[0].Limit != defaultListLimit || collectibles.filters[0].Query != "Alpha" ||
|
||||
collectibles.filters[1].Limit != maxListLimit {
|
||||
t.Fatalf("filters = %#v", collectibles.filters)
|
||||
}
|
||||
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Status: "sold"}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
|
||||
t.Fatalf("List accepted an unmodelled status")
|
||||
}
|
||||
|
||||
if _, err := service.Transfers(context.Background(), 7, 0); err != nil {
|
||||
t.Fatalf("Transfers: %v", err)
|
||||
}
|
||||
if len(collectibles.logLimits) != 1 || collectibles.logLimits[0] != defaultTransferLimit {
|
||||
t.Fatalf("transfer log limits = %#v", collectibles.logLimits)
|
||||
}
|
||||
if _, err := service.Transfers(context.Background(), 0, 10); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("Transfers accepted a zero collectible id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWithoutStoresReportsConfiguration(t *testing.T) {
|
||||
service := NewService()
|
||||
if service.Configured() {
|
||||
t.Fatal("Configured = true without stores")
|
||||
}
|
||||
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
|
||||
t.Fatal("PeerUsernames accepted a missing registry store")
|
||||
}
|
||||
if _, err := service.ToggleUsername(context.Background(), testUser, "alpha", true); err == nil {
|
||||
t.Fatal("ToggleUsername accepted a missing registry store")
|
||||
}
|
||||
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{Username: "alpha"}); err == nil {
|
||||
t.Fatal("Mint accepted a missing collectible store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilServiceIsSafe(t *testing.T) {
|
||||
var service *Service
|
||||
service.SetPeerUsernameNotifier(&recordingNotifier{})
|
||||
if service.Configured() {
|
||||
t.Fatal("nil service reported configured")
|
||||
}
|
||||
if url := service.CollectibleURL("alpha"); url != "" {
|
||||
t.Fatalf("nil service URL = %q", url)
|
||||
}
|
||||
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
|
||||
t.Fatal("nil service PeerUsernames returned no error")
|
||||
}
|
||||
if _, _, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{Username: "alpha", To: testUser}); err == nil {
|
||||
t.Fatal("nil service Transfer returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierFailureDoesNotFailTheMutation(t *testing.T) {
|
||||
registry := newFakeRegistry()
|
||||
registry.lists[testUser] = []domain.Username{
|
||||
{Username: "editable", Active: true, Editable: true},
|
||||
{Username: "alpha", Active: true, CollectibleID: 1},
|
||||
}
|
||||
notifier := &recordingNotifier{err: errors.New("push failed")}
|
||||
service := NewService(
|
||||
WithRegistryStore(registry),
|
||||
WithCollectibleStore(newFakeCollectibles()),
|
||||
WithNotifier(notifier),
|
||||
)
|
||||
|
||||
changed, err := service.ToggleUsername(context.Background(), testUser, "alpha", false)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ToggleUsername = %v, %v; committed mutation must survive a failed push", changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceDeleteNotifiesPreviousOwner covers the hard delete: the request is
|
||||
// normalised and validated before the store is touched, and the peer that held
|
||||
// the asset is invalidated so its projection stops advertising the username.
|
||||
func TestServiceDeleteNotifiesPreviousOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
registry := newFakeRegistry()
|
||||
collectibles := newFakeCollectibles()
|
||||
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 501}
|
||||
collectibles.assets["gone"] = domain.CollectibleUsername{
|
||||
ID: 9, Username: "Gone", Status: domain.CollectibleUsernameStatusOwned, Owner: holder,
|
||||
}
|
||||
svc, notifier := newTestService(t, registry, collectibles)
|
||||
|
||||
deleted, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: " @Gone ", Actor: "admin", Reason: "issued by mistake",
|
||||
})
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
if len(collectibles.deletes) != 1 || collectibles.deletes[0].Username != "Gone" {
|
||||
t.Fatalf("store received %+v, want the normalised name", collectibles.deletes)
|
||||
}
|
||||
if len(notifier.peers) != 1 || notifier.peers[0] != holder {
|
||||
t.Fatalf("notified peers = %#v, want the previous owner %+v", notifier.peers, holder)
|
||||
}
|
||||
|
||||
// An invalid name never reaches the store.
|
||||
before := len(collectibles.deletes)
|
||||
if _, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{Username: "no"}); err == nil {
|
||||
t.Fatalf("delete of a too-short name = nil error, want rejection")
|
||||
}
|
||||
if len(collectibles.deletes) != before {
|
||||
t.Fatalf("store was called with an invalid request: %+v", collectibles.deletes)
|
||||
}
|
||||
|
||||
// Nothing live left is not an error, and nothing is notified.
|
||||
notifier.peers = nil
|
||||
deleted, err = svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: "absentname", Actor: "admin", Reason: "again",
|
||||
})
|
||||
if err != nil || deleted {
|
||||
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
|
||||
}
|
||||
if len(notifier.peers) != 0 {
|
||||
t.Fatalf("no-op delete notified %+v", notifier.peers)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue