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>
845 lines
32 KiB
Go
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)
|
|
}
|
|
}
|