owpengram-server/internal/store/memory/collectible_username_test.go
Egor Egorov fff8de783a
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>
2026-07-28 01:18:00 +08:00

845 lines
32 KiB
Go

package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var (
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
)
func collectibleMintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest {
return domain.MintCollectibleUsernameRequest{
Username: username,
Owner: owner,
PurchaseDate: time.Unix(1700000000, 0).UTC(),
Currency: domain.CollectibleCurrencyStars,
Amount: 2500,
Actor: "admin",
Reason: "unit test",
CommandKey: commandKey,
}
}
func mustMintCollectible(t *testing.T, s *CollectibleUsernameStore, username string, owner domain.Peer) domain.CollectibleUsername {
t.Helper()
asset, created, err := s.MintCollectibleUsername(context.Background(),
collectibleMintRequest(username, owner, "mint-"+username))
if err != nil || !created {
t.Fatalf("mint %s: created=%v err=%v", username, created, err)
}
return asset
}
func mustSetEditable(t *testing.T, s *CollectibleUsernameStore, peer domain.Peer, username string) {
t.Helper()
changed, err := s.SetEditableUsername(context.Background(), peer, username)
if err != nil || !changed {
t.Fatalf("set editable %s: changed=%v err=%v", username, changed, err)
}
}
// usernameRow finds a row by name the way the registry keys it: case-insensitively.
func usernameRow(t *testing.T, rows []domain.Username, username string) domain.Username {
t.Helper()
want := domain.NormalizeUsername(username)
for _, row := range rows {
if strings.EqualFold(row.Username, want) {
return row
}
}
t.Fatalf("username %q missing from %+v", username, rows)
return domain.Username{}
}
func TestCollectibleUsernameMint(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 2002}
tests := []struct {
name string
seed func(t *testing.T, s *CollectibleUsernameStore)
req domain.MintCollectibleUsernameRequest
wantErr error
wantCreated bool
check func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername)
}{
{
name: "into vault",
req: collectibleMintRequest("vaultname", domain.Peer{}, "cmd-vault"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != (domain.Peer{}) || asset.OriginalOwner != (domain.Peer{}) {
t.Fatalf("vault asset carries an owner: %+v", asset)
}
if asset.Version != 1 || asset.TransferCount != 0 {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "with owner",
req: collectibleMintRequest("OwnedName", holder, "cmd-owned"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusOwned || !asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != holder || asset.OriginalOwner != holder {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
row := rows[0]
if row.Username != "OwnedName" || !row.Active || row.Editable ||
row.CollectibleID != asset.ID {
t.Fatalf("row=%+v", row)
}
},
},
{
name: "channel owner",
req: collectibleMintRequest("chanpost", channel, "cmd-chan"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
rows, err := s.PeerUsernames(ctx, channel)
if err != nil || len(rows) != 1 || rows[0].CollectibleID != asset.ID {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "command key replay",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("replayed", holder, "cmd-replay")); err != nil || !created {
t.Fatalf("seed mint created=%v err=%v", created, err)
}
},
req: collectibleMintRequest("replayed", holder, "cmd-replay"),
wantCreated: false,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Username != "replayed" || asset.ID != 1 {
t.Fatalf("replay returned %+v", asset)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 1 || log[0].Kind != domain.CollectibleUsernameKindMint {
t.Fatalf("replay appended provenance: %+v err=%v", log, err)
}
},
},
{
name: "name held by another asset",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustMintCollectible(t, s, "TakenName", holder)
},
req: collectibleMintRequest("takenname", channel, "cmd-dup"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "name held by an editable slot",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustSetEditable(t, s, holder, "EditableOne")
},
req: collectibleMintRequest("editableone", channel, "cmd-editable"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "syntactically invalid",
req: collectibleMintRequest("ab", holder, "cmd-short"),
wantErr: domain.ErrUsernameInvalid,
},
{
name: "crypto pair without amount",
req: func() domain.MintCollectibleUsernameRequest {
req := collectibleMintRequest("cryptoname", holder, "cmd-crypto")
req.CryptoCurrency = domain.CollectibleCryptoCurrencyTON
return req
}(),
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
if tc.seed != nil {
tc.seed(t, s)
}
asset, created, err := s.MintCollectibleUsername(ctx, tc.req)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if created != tc.wantCreated {
t.Fatalf("created=%v want %v", created, tc.wantCreated)
}
if tc.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, asset)
}
})
}
}
func TestCollectibleUsernamePeerLimit(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ {
mustMintCollectible(t, s, fmt.Sprintf("holder%04d", i), holder)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("overflow", holder, "cmd-overflow")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("mint over the limit err=%v", err)
}
// The rejected mint left no asset behind, so the name is still free.
if _, err := s.CollectibleUsername(ctx, "overflow"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint stored an asset: %v", err)
}
spare := mustMintCollectible(t, s, "sparename", other)
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: spare.Username, To: holder, Actor: "admin", CommandKey: "cmd-limit-transfer",
}); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("transfer over the limit err=%v", err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
// The failed transfer did not move the asset either.
stored, err := s.CollectibleUsername(ctx, "sparename")
if err != nil || stored.Owner != other {
t.Fatalf("stored=%+v err=%v", stored, err)
}
}
func TestCollectibleUsernameTransfer(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
owned := mustMintCollectible(t, s, "alphaone", holder)
vaulted := mustMintCollectible(t, s, "vaultone", domain.Peer{})
// Out of the vault: the asset gains a holder and its first original owner.
moved, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "VaultOne", To: holder, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || !changed {
t.Fatalf("transfer out of vault changed=%v err=%v", changed, err)
}
if moved.ID != vaulted.ID || moved.Owner != holder || moved.OriginalOwner != holder ||
moved.TransferCount != 1 || moved.Version != vaulted.Version+1 {
t.Fatalf("moved=%+v", moved)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The editable slot stays first, untouched and editable.
if rows[0].Username != "holderslot" || !rows[0].Editable || !rows[0].Active ||
rows[0].CollectibleID != 0 {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Username != "alphaone" || rows[2].Username != "vaultone" {
t.Fatalf("collectible order=%+v", rows)
}
// Replaying the command key is a no-op.
replay, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultone", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || changed || replay.Owner != holder {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
// Handing an asset to its current holder changes nothing.
same, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: holder, Actor: "admin", CommandKey: "cmd-noop",
})
if err != nil || changed || same.Version != owned.Version {
t.Fatalf("no-op transfer=%+v changed=%v err=%v", same, changed, err)
}
// Between peers: the previous holder loses the registry row, the editable
// slot survives, and the original owner is preserved.
handed, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: other, Actor: "admin", CommandKey: "cmd-handover",
})
if err != nil || !changed {
t.Fatalf("handover changed=%v err=%v", changed, err)
}
if handed.Owner != other || handed.OriginalOwner != holder || handed.TransferCount != 1 {
t.Fatalf("handed=%+v", handed)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if usernameRow(t, rows, "holderslot").Editable != true {
t.Fatalf("editable slot lost: %+v", rows)
}
for _, row := range rows {
if row.Username == "alphaone" {
t.Fatalf("old owner kept the registry row: %+v", rows)
}
}
batch, err := s.PeerUsernamesBatch(ctx, []domain.Peer{holder, other, {Type: domain.PeerTypeUser, ID: 9}})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if len(batch[other]) != 1 || batch[other][0].Username != "alphaone" ||
batch[other][0].SortOrder != 0 {
t.Fatalf("new owner rows=%+v", batch[other])
}
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "missingone", To: other, Actor: "admin",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("transfer of unknown asset err=%v", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, owned.ID, 10)
if err != nil || len(log) != 2 {
t.Fatalf("provenance=%+v err=%v", log, err)
}
if log[0].Kind != domain.CollectibleUsernameKindTransfer || log[0].From != holder || log[0].To != other {
t.Fatalf("newest provenance=%+v", log[0])
}
if log[1].Kind != domain.CollectibleUsernameKindMint || log[1].To != holder {
t.Fatalf("oldest provenance=%+v", log[1])
}
}
func TestCollectibleUsernameRevokeAndBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
revoked := mustMintCollectible(t, s, "revokeme", holder)
burned := mustMintCollectible(t, s, "burnme", holder)
asset, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "RevokeMe", Actor: "admin", Reason: "abuse", CommandKey: "cmd-revoke",
})
if err != nil || !changed {
t.Fatalf("revoke changed=%v err=%v", changed, err)
}
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() ||
asset.Owner != (domain.Peer{}) || asset.OriginalOwner != holder ||
asset.Version != revoked.Version+1 {
t.Fatalf("revoked asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "burnme" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// Back in the vault the asset still owns its name: nobody else can take it.
if _, err := s.SetEditableUsername(ctx, other, "revokeme"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name became claimable: %v", err)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("revokeme", other, "cmd-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name was re-minted: %v", err)
}
// Replay and a second revoke of a vault asset.
if replay, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke",
}); err != nil || changed || replay.Version != asset.Version {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke-again",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotOwned) {
t.Fatalf("revoke of an unowned asset err=%v", err)
}
dead, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Burn: true, Actor: "admin", CommandKey: "cmd-burn",
})
if err != nil || !changed {
t.Fatalf("burn changed=%v err=%v", changed, err)
}
if dead.Status != domain.CollectibleUsernameStatusBurned || dead.Owner != (domain.Peer{}) ||
dead.OriginalOwner != holder || dead.Version != burned.Version+1 {
t.Fatalf("burned asset=%+v", dead)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("burn left registry rows: %+v err=%v", rows, err)
}
// The burn released the name: another peer may occupy it again.
if changed, err := s.SetEditableUsername(ctx, other, "BurnMe"); err != nil || !changed {
t.Fatalf("claim freed name changed=%v err=%v", changed, err)
}
claimed, err := s.PeerUsernames(ctx, other)
if err != nil || len(claimed) != 1 || claimed[0].Username != "BurnMe" || !claimed[0].Editable {
t.Fatalf("claimed=%+v err=%v", claimed, err)
}
// The burned asset row survives for provenance, so the name never mints twice.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("burnme", holder, "cmd-burn-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("burned name was re-minted: %v", err)
}
for _, err := range []error{
mustErr(s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "burnme", To: other, Actor: "admin", CommandKey: "cmd-burn-transfer",
})),
mustErr(s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Actor: "admin", CommandKey: "cmd-burn-revoke",
})),
} {
if !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("mutation of a burned asset err=%v", err)
}
}
log, err := s.CollectibleUsernameTransfers(ctx, dead.ID, 10)
if err != nil || len(log) != 2 || log[0].Kind != domain.CollectibleUsernameKindBurn ||
log[0].From != holder {
t.Fatalf("burn provenance=%+v err=%v", log, err)
}
listed, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusBurned,
})
if err != nil || len(listed) != 1 || listed[0].ID != dead.ID {
t.Fatalf("listed=%+v err=%v", listed, err)
}
byID, err := s.CollectibleUsernameByID(ctx, dead.ID)
if err != nil || byID.Username != "burnme" {
t.Fatalf("byID=%+v err=%v", byID, err)
}
}
func mustErr[T any](_ T, _ bool, err error) error { return err }
func TestCollectibleUsernameToggle(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
lonely := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
tests := []struct {
name string
peer domain.Peer
username string
active bool
wantErr error
wantChanged bool
wantActive bool
}{
{name: "deactivate collectible", peer: holder, username: "alphaone", active: false, wantChanged: true},
{name: "activating an already active row", peer: holder, username: "ALPHAONE", active: true, wantChanged: false, wantActive: true},
{name: "editable slot is off limits", peer: holder, username: "holderslot", active: false, wantErr: domain.ErrUsernameNotCollectible, wantActive: true},
{name: "unknown username", peer: holder, username: "nothere", active: false, wantErr: domain.ErrUsernameNotOccupied},
{name: "last active collectible may be deactivated", peer: lonely, username: "lonelyone", active: false, wantChanged: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "lonelyone", lonely)
changed, err := s.SetUsernameActive(ctx, tc.peer, tc.username, tc.active)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
if tc.username == "nothere" {
return
}
rows, err := s.PeerUsernames(ctx, tc.peer)
if err != nil {
t.Fatal(err)
}
row := usernameRow(t, rows, tc.username)
if row.Active != tc.wantActive {
t.Fatalf("row=%+v want active=%v", row, tc.wantActive)
}
})
}
t.Run("deactivate all keeps the editable slot", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
changed, err := s.DeactivateAllUsernames(ctx, holder)
if err != nil || !changed {
t.Fatalf("deactivate all changed=%v err=%v", changed, err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if !rows[0].Editable || !rows[0].Active {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Active || rows[2].Active {
t.Fatalf("collectibles still active: %+v", rows)
}
if changed, err := s.DeactivateAllUsernames(ctx, holder); err != nil || changed {
t.Fatalf("second deactivate changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameReorder(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
tests := []struct {
name string
order []string
wantErr error
wantChanged bool
wantOrder []string
}{
{
// Clients send the whole active list, editable slot included.
name: "valid permutation",
order: []string{"holderslot", "gammathree", "@AlphaOne", "betatwo"},
wantChanged: true,
wantOrder: []string{"holderslot", "gammathree", "alphaone", "betatwo"},
},
{
name: "identity permutation",
order: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
wantChanged: false,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
// The editable slot is reorderable: a collectible may be made primary.
name: "collectible ahead of the editable slot",
order: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
wantChanged: true,
wantOrder: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
},
{
name: "partial order",
order: []string{"holderslot", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "active editable slot omitted",
order: []string{"alphaone", "betatwo", "gammathree"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "duplicate entry",
order: []string{"holderslot", "alphaone", "alphaone", "betatwo"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "unknown username",
order: []string{"holderslot", "alphaone", "betatwo", "nothere"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "garbage input",
order: []string{"holderslot", "", "@", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "gammathree", holder)
changed, err := s.ReorderUsernames(ctx, holder, tc.order)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatal(err)
}
want := tc.wantOrder
if len(rows) != len(want) {
t.Fatalf("rows=%+v want %v", rows, want)
}
for i, name := range want {
if rows[i].Username != name {
t.Fatalf("rows=%+v want %v", rows, want)
}
}
})
}
// A peer whose only username is the editable slot: sending just that name is
// the identity order, and sending nothing at all is the no-op every client
// gets when it reconciles an empty collectible list.
t.Run("editable slot only", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
if changed, err := s.ReorderUsernames(ctx, holder, []string{"holderslot"}); err != nil || changed {
t.Fatalf("editable-only order: changed=%v err=%v", changed, err)
}
})
t.Run("no usernames at all", func(t *testing.T) {
s := NewCollectibleUsernameStore()
if changed, err := s.ReorderUsernames(ctx, holder, nil); err != nil || changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameRegistryUniqueness(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
// One editable row per peer: setting a new one replaces the old.
mustSetEditable(t, s, holder, "secondslot")
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "secondslot" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The released name is free again, case-insensitively.
if changed, err := s.SetEditableUsername(ctx, other, "HOLDERSLOT"); err != nil || !changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
if _, err := s.SetEditableUsername(ctx, holder, "holderslot"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("occupied name reused: %v", err)
}
if changed, err := s.SetEditableUsername(ctx, other, ""); err != nil || !changed {
t.Fatalf("clear editable changed=%v err=%v", changed, err)
}
if rows, err := s.PeerUsernames(ctx, other); err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// A vault asset owns its name even without a registry row, so the editable
// slot cannot take it and the asset stays handable.
vaulted := mustMintCollectible(t, s, "vaultname", domain.Peer{})
if _, err := s.SetEditableUsername(ctx, other, "VaultName"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("vault name became claimable: %v", err)
}
if moved, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultname", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
}); err != nil || moved.ID != vaulted.ID || moved.Owner != other {
t.Fatalf("moved=%+v err=%v", moved, err)
}
// Returned slices are copies: mutating them cannot change stored state.
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
rows[0].Username = "mutated"
rows[0].Active = false
again, err := s.PeerUsernames(ctx, holder)
if err != nil || again[0].Username != "secondslot" || !again[0].Active {
t.Fatalf("stored state mutated through the returned slice: %+v err=%v", again, err)
}
}
func TestCollectibleUsernameListingAndPaging(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
first := mustMintCollectible(t, s, "alphaone", holder)
second := mustMintCollectible(t, s, "alphatwo", other)
third := mustMintCollectible(t, s, "betathree", domain.Peer{})
all, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{})
if err != nil || len(all) != 3 || all[0].ID != third.ID || all[2].ID != first.ID {
t.Fatalf("all=%+v err=%v", all, err)
}
page, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Limit: 2})
if err != nil || len(page) != 2 || page[0].ID != third.ID {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
BeforeID: page[len(page)-1].ID, Limit: 2,
})
if err != nil || len(next) != 1 || next[0].ID != first.ID {
t.Fatalf("next=%+v err=%v", next, err)
}
owned, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Owner: other})
if err != nil || len(owned) != 1 || owned[0].ID != second.ID {
t.Fatalf("owned=%+v err=%v", owned, err)
}
matched, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Query: "ALPHA"})
if err != nil || len(matched) != 2 {
t.Fatalf("matched=%+v err=%v", matched, err)
}
vault, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusVault,
})
if err != nil || len(vault) != 1 || vault[0].ID != third.ID {
t.Fatalf("vault=%+v err=%v", vault, err)
}
if _, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus("gone"),
}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
t.Fatalf("invalid status filter err=%v", err)
}
if _, err := s.CollectibleUsername(ctx, "@AlphaOne"); err != nil {
t.Fatalf("lookup by display form: %v", err)
}
if _, err := s.CollectibleUsername(ctx, ""); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("empty lookup err=%v", err)
}
if _, err := s.CollectibleUsernameByID(ctx, 4242); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("unknown id err=%v", err)
}
}
// TestCollectibleUsernameReissueAfterBurn covers migration 0152: burning retires
// the asset but releases the name, so the same name can be issued again while the
// burned rows stay as provenance.
func TestCollectibleUsernameReissueAfterBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4001}
first := mustMintCollectible(t, s, "Nfts", holder)
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-1",
}); err != nil {
t.Fatalf("burn: %v", err)
}
second, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("NFTS", holder, "mint-again-1"))
if err != nil || !created {
t.Fatalf("reissue after burn: created=%v err=%v", created, err)
}
if second.ID == first.ID {
t.Fatalf("reissue reused asset id %d", second.ID)
}
if second.Status != domain.CollectibleUsernameStatusOwned {
t.Fatalf("reissued status = %q, want owned", second.Status)
}
// The name now resolves to the live asset, not to either burned row.
live, err := s.CollectibleUsername(ctx, "nfts")
if err != nil {
t.Fatalf("lookup after reissue: %v", err)
}
if live.ID != second.ID {
t.Fatalf("lookup id = %d, want the live asset %d", live.ID, second.ID)
}
// The burned row is still readable by identity: it is the provenance record.
burned, err := s.CollectibleUsernameByID(ctx, first.ID)
if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("burned row = %+v err=%v", burned, err)
}
// A second burn releases the name again, so the cycle repeats.
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-2",
}); err != nil {
t.Fatalf("second burn: %v", err)
}
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-2")); err != nil || !created {
t.Fatalf("second reissue: created=%v err=%v", created, err)
}
// A live asset still blocks a mint, burned history or not.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-3")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err)
}
}
func TestCollectibleUsernameDelete(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4101}
mustSetEditable(t, s, holder, "holder_main")
asset := mustMintCollectible(t, s, "Gone", holder)
deleted, err := s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "@gone", Actor: "admin", Reason: "issued by mistake", CommandKey: "del-1",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if _, err := s.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("asset after delete err = %v, want not found", err)
}
if _, err := s.CollectibleUsername(ctx, "gone"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("lookup after delete err = %v, want not found", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 0 {
t.Fatalf("provenance after delete = %d rows err=%v, want none", len(log), err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatalf("peer usernames: %v", err)
}
if len(rows) != 1 || !rows[0].Editable {
t.Fatalf("owner rows after delete = %+v, want only the editable slot", rows)
}
// The name is completely free afterwards, for a collectible or an editable slot.
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("gone", domain.Peer{}, "mint-after-delete")); err != nil || !created {
t.Fatalf("mint after delete: created=%v err=%v", created, err)
}
// A repeat is a no-op rather than an error: the record a command key would
// resolve to is gone, so idempotency degrades to "nothing live left".
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "gone", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-after-delete",
}); err != nil {
t.Fatalf("burn reissued asset: %v", err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "gone", Actor: "admin", Reason: "again", CommandKey: "del-2",
})
if err != nil || deleted {
t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "never_issued", Actor: "admin", Reason: "again", CommandKey: "del-3",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
}