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
845
internal/store/memory/collectible_username_test.go
Normal file
845
internal/store/memory/collectible_username_test.go
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue