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
|
|
@ -7,16 +7,28 @@ import (
|
|||
"encoding/hex"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
stargiftapp "telesrv/internal/app/stargifts"
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
// cmd/telesrv wires *usernames.Service and *rating.Service into
|
||||
// Dependencies.Usernames / Dependencies.Rating directly, so a drifting method set
|
||||
// has to fail here rather than at integration time.
|
||||
var (
|
||||
_ CollectibleUsernamesService = (*usernamesapp.Service)(nil)
|
||||
_ AccountRatingService = (*ratingapp.Service)(nil)
|
||||
)
|
||||
|
||||
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
|
|
@ -1311,3 +1323,653 @@ func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.
|
|||
f.channels = append(f.channels, ch.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeCollectibleUsernamesService is an in-memory collectible lifecycle: enough
|
||||
// state to observe occupancy, replay-by-command-key and the burned terminal
|
||||
// state, which is exactly what the admin commands are expected to reason about.
|
||||
type fakeCollectibleUsernamesService struct {
|
||||
assets map[string]domain.CollectibleUsername
|
||||
log map[int64][]domain.CollectibleUsernameTransfer
|
||||
commandKeys map[string]int64
|
||||
nextID int64
|
||||
mintCalls int
|
||||
transferCalls int
|
||||
revokeCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func newFakeCollectibleUsernames() *fakeCollectibleUsernamesService {
|
||||
return &fakeCollectibleUsernamesService{
|
||||
assets: map[string]domain.CollectibleUsername{},
|
||||
log: map[int64][]domain.CollectibleUsernameTransfer{},
|
||||
commandKeys: map[string]int64{},
|
||||
nextID: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Mint(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.mintCalls++
|
||||
if id, ok := f.commandKeys[req.CommandKey]; ok && req.CommandKey != "" {
|
||||
return f.byID(id), false, nil
|
||||
}
|
||||
key := strings.ToLower(req.Username)
|
||||
if _, ok := f.assets[key]; ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
f.nextID++
|
||||
asset := domain.CollectibleUsername{
|
||||
ID: f.nextID, Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
|
||||
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount,
|
||||
CryptoCurrency: req.CryptoCurrency, CryptoAmount: req.CryptoAmount, URL: req.URL,
|
||||
Version: 1,
|
||||
}
|
||||
if req.Owner.Type != "" {
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.Owner
|
||||
asset.OriginalOwner = req.Owner
|
||||
}
|
||||
f.assets[key] = asset
|
||||
f.commandKeys[req.CommandKey] = asset.ID
|
||||
f.log[asset.ID] = append(f.log[asset.ID], domain.CollectibleUsernameTransfer{
|
||||
ID: asset.ID, CollectibleID: asset.ID, Kind: domain.CollectibleUsernameKindMint,
|
||||
To: req.Owner, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey,
|
||||
})
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Transfer(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.transferCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
if asset.Owned() && asset.Owner == req.To {
|
||||
return asset, false, nil
|
||||
}
|
||||
asset.Status = domain.CollectibleUsernameStatusOwned
|
||||
asset.Owner = req.To
|
||||
if asset.OriginalOwner.Type == "" {
|
||||
asset.OriginalOwner = req.To
|
||||
}
|
||||
asset.TransferCount++
|
||||
asset.Version++
|
||||
f.assets[key] = asset
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Delete(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
f.deleteCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.assets, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Revoke(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
f.revokeCalls++
|
||||
key := strings.ToLower(req.Username)
|
||||
asset, ok := f.assets[key]
|
||||
if !ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
asset.Owner = domain.Peer{}
|
||||
asset.Status = domain.CollectibleUsernameStatusVault
|
||||
if req.Burn {
|
||||
asset.Status = domain.CollectibleUsernameStatusBurned
|
||||
}
|
||||
asset.Version++
|
||||
f.assets[key] = asset
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Collectible(_ 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 *fakeCollectibleUsernamesService) List(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
out := make([]domain.CollectibleUsername, 0, len(f.assets))
|
||||
for _, asset := range f.assets {
|
||||
if filter.Status != "" && asset.Status != filter.Status {
|
||||
continue
|
||||
}
|
||||
if filter.BeforeID != 0 && asset.ID >= filter.BeforeID {
|
||||
continue
|
||||
}
|
||||
out = append(out, asset)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if filter.Limit > 0 && len(out) > filter.Limit {
|
||||
out = out[:filter.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) Transfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return f.log[collectibleID], nil
|
||||
}
|
||||
|
||||
func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsername {
|
||||
for _, asset := range f.assets {
|
||||
if asset.ID == id {
|
||||
return asset
|
||||
}
|
||||
}
|
||||
return domain.CollectibleUsername{}
|
||||
}
|
||||
|
||||
// fakeAccountRatingService recomputes from the manual component only, which
|
||||
// keeps the arithmetic in the domain and the fake focused on ledger replay.
|
||||
type fakeAccountRatingService struct {
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
commandKeys map[string]bool
|
||||
recomputeCalls int
|
||||
adjustCalls int
|
||||
}
|
||||
|
||||
func newFakeAccountRating() *fakeAccountRatingService {
|
||||
return &fakeAccountRatingService{
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
commandKeys: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.recomputeCalls++
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
|
||||
}, domain.DefaultAccountRatingWeights(), fixedNow())
|
||||
rating.Version = f.ratings[userID].Version + 1
|
||||
f.ratings[userID] = rating
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
f.adjustCalls++
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
applied := true
|
||||
if f.commandKeys[req.CommandKey] {
|
||||
applied = false
|
||||
} else {
|
||||
f.commandKeys[req.CommandKey] = true
|
||||
f.manual[req.UserID] += req.Amount
|
||||
}
|
||||
rating, err := f.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level < filter.MinLevel {
|
||||
continue
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
if f.manual[userID] == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
|
||||
dry, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-mint", Actor: "ops", Reason: "fragment import", DryRun: true},
|
||||
Username: "@Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run mint: %v", err)
|
||||
}
|
||||
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 0 {
|
||||
t.Fatalf("dry-run result=%+v mintCalls=%d, want validation without mutation", dry, usernames.mintCalls)
|
||||
}
|
||||
if dry.Details["username"] != "Durov" || dry.Details["purchase_date"] != fixedNow().Format(time.RFC3339) {
|
||||
t.Fatalf("dry-run details=%+v, want a normalised name and a stamped purchase date", dry.Details)
|
||||
}
|
||||
|
||||
execReq := MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-mint", Actor: "ops", Reason: "fragment import"},
|
||||
Username: "Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
|
||||
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
|
||||
}
|
||||
exec, err := svc.MintCollectibleUsername(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute mint: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 1 ||
|
||||
exec.Details["status"] != string(domain.CollectibleUsernameStatusOwned) {
|
||||
t.Fatalf("execute result=%+v mintCalls=%d", exec, usernames.mintCalls)
|
||||
}
|
||||
|
||||
again, err := svc.MintCollectibleUsername(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay mint: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || usernames.mintCalls != 1 {
|
||||
t.Fatalf("replay result=%+v mintCalls=%d, want idempotent replay", again, usernames.mintCalls)
|
||||
}
|
||||
|
||||
occupied, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-mint-2", Actor: "ops", Reason: "duplicate"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), CodeUsernameOccupied) {
|
||||
t.Fatalf("duplicate mint err=%v, want %s", err, CodeUsernameOccupied)
|
||||
}
|
||||
if occupied.Status != string(domain.AdminCommandFailed) || usernames.mintCalls != 1 {
|
||||
t.Fatalf("duplicate result=%+v mintCalls=%d, want a journalled failure without mutation", occupied, usernames.mintCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameValidatesBeforeJournallingCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req MintCollectibleUsernameRequest
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "short username",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-1", Actor: "ops", Reason: "invalid"},
|
||||
Username: "ab", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
},
|
||||
code: CodeUsernameInvalid,
|
||||
},
|
||||
{
|
||||
name: "unsupported currency",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-2", Actor: "ops", Reason: "invalid"},
|
||||
Username: "durov", Currency: "EUR", Amount: 1,
|
||||
},
|
||||
code: CodeCollectibleCurrencyInvalid,
|
||||
},
|
||||
{
|
||||
name: "crypto amount without currency",
|
||||
req: MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-3", Actor: "ops", Reason: "invalid"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CryptoAmount: 5,
|
||||
},
|
||||
code: CodeCollectibleCurrencyInvalid,
|
||||
},
|
||||
}
|
||||
for _, item := range cases {
|
||||
if _, err := svc.MintCollectibleUsername(ctx, item.req); err == nil || !strings.Contains(err.Error(), item.code) {
|
||||
t.Fatalf("%s err=%v, want %s", item.name, err, item.code)
|
||||
}
|
||||
if _, journalled := repo.items[item.req.CommandID]; journalled {
|
||||
t.Fatalf("%s journalled a rejected command", item.name)
|
||||
}
|
||||
}
|
||||
if usernames.mintCalls != 0 {
|
||||
t.Fatalf("rejected requests reached the lifecycle: mintCalls=%d", usernames.mintCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferCollectibleUsernameRequiresRecipientAndRejectsBurned(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-none", Actor: "ops", Reason: "sold"}, Username: "durov",
|
||||
}); err == nil || !strings.Contains(err.Error(), "to_user_id") {
|
||||
t.Fatalf("transfer without recipient err=%v", err)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-both", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1001, ToChannelID: 2002,
|
||||
}); err == nil {
|
||||
t.Fatal("transfer accepted two recipients")
|
||||
}
|
||||
|
||||
dry, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-dry", Actor: "ops", Reason: "sold", DryRun: true},
|
||||
Username: "durov", ToUserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run transfer: %v", err)
|
||||
}
|
||||
if usernames.transferCalls != 0 || dry.Details["would_change"] != true {
|
||||
t.Fatalf("dry-run transfer result=%+v transferCalls=%d", dry, usernames.transferCalls)
|
||||
}
|
||||
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-exec", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1001,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute transfer: %v", err)
|
||||
}
|
||||
if usernames.transferCalls != 1 {
|
||||
t.Fatalf("transferCalls=%d, want one mutation", usernames.transferCalls)
|
||||
}
|
||||
|
||||
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "burn-1", Actor: "ops", Reason: "fraud"},
|
||||
Username: "durov", Burn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("burn: %v", err)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-burned", Actor: "ops", Reason: "sold"},
|
||||
Username: "durov", ToUserID: 1002,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
|
||||
t.Fatalf("transfer of a burned asset err=%v, want %s", err, CodeCollectibleBurned)
|
||||
}
|
||||
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "t-missing", Actor: "ops", Reason: "sold"},
|
||||
Username: "nobody_holds_this", ToUserID: 1002,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotFound) {
|
||||
t.Fatalf("transfer of a missing asset err=%v, want %s", err, CodeCollectibleNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCollectibleUsernameRejectsVaultAssetWithoutBurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-vault", Actor: "ops", Reason: "nothing to take"},
|
||||
Username: "durov",
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotOwned) {
|
||||
t.Fatalf("revoke of a vault asset err=%v, want %s", err, CodeCollectibleNotOwned)
|
||||
}
|
||||
if usernames.revokeCalls != 0 {
|
||||
t.Fatalf("revokeCalls=%d, want no mutation", usernames.revokeCalls)
|
||||
}
|
||||
|
||||
burnDry, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-burn-dry", Actor: "ops", Reason: "fraud", DryRun: true},
|
||||
Username: "durov", Burn: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run burn: %v", err)
|
||||
}
|
||||
if usernames.revokeCalls != 0 || burnDry.Details["burn"] != true {
|
||||
t.Fatalf("dry-run burn result=%+v revokeCalls=%d", burnDry, usernames.revokeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Usernames: usernames, Now: fixedNow})
|
||||
first, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "telegram", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-2",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CollectibleUsernameByID(ctx, first.ID)
|
||||
if err != nil || got.ID != first.ID || got.Username != "durov" {
|
||||
t.Fatalf("CollectibleUsernameByID(%d) = %+v err=%v", first.ID, got, err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernameByID(ctx, first.ID-1); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("missing id err=%v, want ErrCollectibleUsernameNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
|
||||
UserID: 1001, Amount: -2500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
|
||||
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
|
||||
}
|
||||
|
||||
execReq := AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
|
||||
UserID: 1001, Amount: -2500,
|
||||
}
|
||||
exec, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
|
||||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
|
||||
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
|
||||
}
|
||||
|
||||
again, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay adjust: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
|
||||
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
|
||||
}
|
||||
|
||||
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
|
||||
UserID: 1001, Amount: amount,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
|
||||
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
|
||||
}
|
||||
}
|
||||
if _, journalled := repo.items["bad-adjust"]; journalled {
|
||||
t.Fatal("journalled a rejected adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
|
||||
}); err == nil || !strings.Contains(err.Error(), "user_id") {
|
||||
t.Fatalf("recompute without user err=%v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
|
||||
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
|
||||
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
stored, err := svc.AccountRating(ctx, 1001)
|
||||
if err != nil || stored.Stars != 5000 {
|
||||
t.Fatalf("AccountRating = %+v err=%v", stored, err)
|
||||
}
|
||||
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
|
||||
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-1", Actor: "ops", Reason: "x"},
|
||||
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
|
||||
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
|
||||
t.Fatalf("mint without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
|
||||
UserID: 1001, Amount: 5,
|
||||
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
|
||||
t.Fatalf("adjust without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
|
||||
t.Fatal("listing without dependency succeeded")
|
||||
}
|
||||
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
|
||||
t.Fatal("rating listing without dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the
|
||||
// journal captures what was removed before the record disappears, a dry-run
|
||||
// mutates nothing, and a burned asset is refused.
|
||||
func TestDeleteCollectibleUsernameCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
usernames := newFakeCollectibleUsernames()
|
||||
repo := newMemoryCommandRepo()
|
||||
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
|
||||
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 8801}
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "wrongname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed mint: %v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-dry", Actor: "ops", Reason: "mistake", DryRun: true},
|
||||
Username: "@wrongname",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if !dry.DryRun || usernames.deleteCalls != 0 {
|
||||
t.Fatalf("dry run mutated: result=%+v calls=%d", dry, usernames.deleteCalls)
|
||||
}
|
||||
if dry.Details["previous_owner_id"] != "8801" {
|
||||
t.Fatalf("dry run details = %+v, want the holder captured", dry.Details)
|
||||
}
|
||||
|
||||
exec, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
|
||||
Username: "wrongname",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
if usernames.deleteCalls != 1 || exec.Details["deleted"] != true {
|
||||
t.Fatalf("exec = %+v calls=%d", exec, usernames.deleteCalls)
|
||||
}
|
||||
if exec.Details["previous_status"] != string(domain.CollectibleUsernameStatusOwned) {
|
||||
t.Fatalf("journal lost the pre-delete state: %+v", exec.Details)
|
||||
}
|
||||
|
||||
// Replaying the same command id must not touch the store again.
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
|
||||
Username: "wrongname",
|
||||
}); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if usernames.deleteCalls != 1 {
|
||||
t.Fatalf("replay called the store again: calls=%d", usernames.deleteCalls)
|
||||
}
|
||||
|
||||
// A burned asset is history: it is released by re-issuing the name, not deleted.
|
||||
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "burnedname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed burned mint: %v", err)
|
||||
}
|
||||
if _, _, err := usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: "burnedname", Burn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed burn: %v", err)
|
||||
}
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-burned", Actor: "ops", Reason: "cleanup"},
|
||||
Username: "burnedname",
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
|
||||
t.Fatalf("delete of burned asset err = %v, want %s", err, CodeCollectibleBurned)
|
||||
}
|
||||
|
||||
// A short name is rejected before a command is journalled at all.
|
||||
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "del-short", Actor: "ops", Reason: "cleanup"},
|
||||
Username: "no",
|
||||
}); err == nil {
|
||||
t.Fatalf("delete of invalid name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue