package postgres import ( "context" "errors" "fmt" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "telesrv/internal/domain" ) // collectibleTestUser inserts a bare user row and returns its user peer. The // registry rows for collectibles reference no peer table, but the editable-slot // assertions and the peer-deletion trigger both need a real user. func collectibleTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, username string) domain.Peer { t.Helper() ctx := context.Background() var id int64 if err := pool.QueryRow(ctx, ` INSERT INTO users (access_hash, phone, first_name, username) VALUES ($1, $2, 'collectible test', $3) RETURNING id`, seed, fmt.Sprintf("%d", seed), username).Scan(&id); err != nil { t.Fatalf("insert collectible test user: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id) }) return domain.Peer{Type: domain.PeerTypeUser, ID: id} } // setEditableUsername installs an editable registry row through the same helper // the client-driven username path uses. func setEditableUsername(t *testing.T, pool *pgxpool.Pool, peer domain.Peer, username string) { t.Helper() ctx := context.Background() tx, err := pool.Begin(ctx) if err != nil { t.Fatalf("begin editable username: %v", err) } defer func() { _ = tx.Rollback(ctx) }() if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, peer.ID, username, lowerASCII(username)); err != nil { t.Fatalf("set editable username %q: %v", username, err) } if err := tx.Commit(ctx); err != nil { t.Fatalf("commit editable username: %v", err) } } func lowerASCII(s string) string { out := []byte(s) for i := range out { if out[i] >= 'A' && out[i] <= 'Z' { out[i] += 'a' - 'A' } } return string(out) } func cleanupCollectible(t *testing.T, pool *pgxpool.Pool, usernameLower string) { t.Helper() t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM collectible_usernames WHERE username_lower = $1`, usernameLower) }) } func mintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest { return domain.MintCollectibleUsernameRequest{ Username: username, Owner: owner, PurchaseDate: time.Now().UTC().Truncate(time.Second), Currency: domain.CollectibleCurrencyStars, Amount: 5000, URL: "https://fragment.example/" + username, Actor: "ops", Reason: "integration test", CommandKey: commandKey, } } func registryRows(t *testing.T, pool *pgxpool.Pool, peer domain.Peer) []domain.Username { t.Helper() list, err := listPeerUsernames(context.Background(), pool, peer) if err != nil { t.Fatalf("list peer usernames: %v", err) } return list } func TestCollectibleUsernameResolveAndSearchUseActiveRegistry(t *testing.T) { pool := testPool(t) ctx := context.Background() seed := time.Now().UnixNano() % 1_000_000 viewer := collectibleTestUser(t, pool, 3_100_000_000+seed, "") userPeer := collectibleTestUser(t, pool, 3_200_000_000+seed, "") userEditable := fmt.Sprintf("uedit%d", seed) userCollectible := fmt.Sprintf("unft%d", seed) setEditableUsername(t, pool, userPeer, userEditable) cleanupCollectible(t, pool, lowerASCII(userCollectible)) registry := NewCollectibleUsernameStore(pool) if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(userCollectible, userPeer, "")); err != nil || !created { t.Fatalf("mint user collectible: created=%v err=%v", created, err) } users := NewUserStore(pool) resolvedUser, found, err := users.ByUsername(ctx, userCollectible) if err != nil || !found || resolvedUser.ID != userPeer.ID { t.Fatalf("resolve user collectible = %+v found=%v err=%v", resolvedUser, found, err) } userSearch, err := users.Search(ctx, viewer.ID, userCollectible, "", 10) if err != nil || len(userSearch.Results) != 1 || userSearch.Results[0].ID != userPeer.ID { t.Fatalf("search user collectible = %+v err=%v", userSearch, err) } if changed, err := registry.SetUsernameActive(ctx, userPeer, userCollectible, false); err != nil || !changed { t.Fatalf("deactivate user collectible: changed=%v err=%v", changed, err) } if _, found, err := users.ByUsername(ctx, userCollectible); err != nil || found { t.Fatalf("resolve inactive user collectible found=%v err=%v", found, err) } if hidden, err := users.Search(ctx, viewer.ID, userCollectible, "", 10); err != nil || len(hidden.Results)+len(hidden.MyResults) != 0 { t.Fatalf("search inactive user collectible = %+v err=%v", hidden, err) } channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: userPeer.ID, Title: "Unrelated collectible channel", Broadcast: true, Date: int(time.Now().Unix()), }) if err != nil { t.Fatalf("create channel: %v", err) } channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM channels WHERE id = $1`, channelPeer.ID) }) channelEditable := fmt.Sprintf("cedit%d", seed) if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ UserID: userPeer.ID, ChannelID: channelPeer.ID, Username: channelEditable, }); err != nil { t.Fatalf("set channel editable username: %v", err) } channelCollectible := fmt.Sprintf("cnft%d", seed) cleanupCollectible(t, pool, lowerASCII(channelCollectible)) if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(channelCollectible, channelPeer, "")); err != nil || !created { t.Fatalf("mint channel collectible: created=%v err=%v", created, err) } resolvedChannel, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible) if err != nil || !found || resolvedChannel.ID != channelPeer.ID { t.Fatalf("resolve channel collectible = %+v found=%v err=%v", resolvedChannel, found, err) } channelSearch, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10) if err != nil || len(channelSearch.Results) != 1 || channelSearch.Results[0].ID != channelPeer.ID { t.Fatalf("search channel collectible = %+v err=%v", channelSearch, err) } if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ UserID: userPeer.ID, ChannelID: channelPeer.ID, Username: "", }); err != nil { t.Fatalf("clear channel editable username: %v", err) } if nftOnly, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || !found || nftOnly.ID != channelPeer.ID { t.Fatalf("resolve NFT-only channel = %+v found=%v err=%v", nftOnly, found, err) } if view, err := channels.GetChannel(ctx, viewer.ID, channelPeer.ID); err != nil || view.Channel.ID != channelPeer.ID { t.Fatalf("preview NFT-only channel = %+v err=%v", view, err) } if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ UserID: userPeer.ID, ChannelID: channelPeer.ID, Username: channelEditable, }); err != nil { t.Fatalf("restore channel editable username: %v", err) } if changed, err := registry.SetUsernameActive(ctx, channelPeer, channelCollectible, false); err != nil || !changed { t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err) } if _, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || found { t.Fatalf("resolve inactive channel collectible found=%v err=%v", found, err) } if hidden, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10); err != nil || len(hidden.Results) != 0 { t.Fatalf("search inactive channel collectible = %+v err=%v", hidden, err) } } func TestCollectibleUsernameSearchPrefixUsesActiveRegistryIndex(t *testing.T) { pool := testPool(t) ctx := context.Background() tx, err := pool.Begin(ctx) if err != nil { t.Fatalf("begin: %v", err) } defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, ` WITH names AS ( SELECT CASE WHEN n = 1 THEN 'nftplanfixture' ELSE 'otherplanfixture' || n::text END AS username, 9100000000 + n AS owner_peer_id FROM generate_series(1, 5000) AS n ), assets AS ( INSERT INTO collectible_usernames ( username, username_lower, status, owner_peer_type, owner_peer_id, purchase_date, currency, amount, original_owner_peer_type, original_owner_peer_id, created_at, updated_at ) SELECT username, username, 'owned', 'user', owner_peer_id, now(), 'XTR', 1, 'user', owner_peer_id, now(), now() FROM names RETURNING id, username, username_lower, owner_peer_id ) INSERT INTO peer_usernames ( username_lower, username, peer_type, peer_id, active, editable, sort_order, collectible_id ) SELECT username_lower, username, 'user', owner_peer_id, true, false, 0, id FROM assets`); err != nil { t.Fatalf("seed username plan fixture: %v", err) } if _, err := tx.Exec(ctx, "ANALYZE peer_usernames"); err != nil { t.Fatalf("analyze username plan fixture: %v", err) } if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil { t.Fatalf("disable seqscan: %v", err) } plan := explainText(t, ctx, tx, ` SELECT peer_id FROM peer_usernames WHERE peer_type = 'user' AND active AND collectible_id IS NOT NULL AND username_lower LIKE $1 || '%' ESCAPE '\'`, "nft") if !strings.Contains(plan, "peer_usernames_active_search_idx") { t.Fatalf("active username prefix plan = %s, want peer_usernames_active_search_idx", plan) } } // TestCollectibleUsernameMintIntoVault covers a vault mint: the asset exists, the // name is not projected into any peer's registry, and the provenance log records // the mint. func TestCollectibleUsernameMintIntoVault(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) name := fmt.Sprintf("vault%d", time.Now().UnixNano()%1_000_000) cleanupCollectible(t, pool, lowerASCII(name)) asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, "")) if err != nil || !created { t.Fatalf("mint into vault created=%v err=%v", created, err) } if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() || asset.Version != 1 || asset.TransferCount != 0 || asset.Username != name { t.Fatalf("vault asset = %+v", asset) } if err := asset.Validate(); err != nil { t.Fatalf("vault asset invariants: %v", err) } var registry int if err := pool.QueryRow(ctx, `SELECT count(*) FROM peer_usernames WHERE collectible_id = $1`, asset.ID).Scan(®istry); err != nil { t.Fatal(err) } if registry != 0 { t.Fatalf("vault asset must not be projected, got %d registry rows", registry) } transfers, err := store.CollectibleUsernameTransfers(ctx, asset.ID, 10) if err != nil || len(transfers) != 1 || transfers[0].Kind != domain.CollectibleUsernameKindMint { t.Fatalf("transfers=%+v err=%v", transfers, err) } // A vault revoke has nothing to release and must stay a no-op. same, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{Username: name}) if err != nil || changed || same.Version != asset.Version { t.Fatalf("vault revoke changed=%v asset=%+v err=%v", changed, same, err) } } // TestCollectibleUsernameMintWithOwnerAndReplay covers a mint that assigns the // asset immediately, and the command-key replay that must return the recorded // state instead of minting twice. func TestCollectibleUsernameMintWithOwnerAndReplay(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 owner := collectibleTestUser(t, pool, 2_100_000_000+seed, "") name := fmt.Sprintf("owned%d", seed) cleanupCollectible(t, pool, lowerASCII(name)) key := fmt.Sprintf("mint-%d", seed) asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key)) if err != nil || !created { t.Fatalf("mint with owner created=%v err=%v", created, err) } if asset.Status != domain.CollectibleUsernameStatusOwned || asset.Owner != owner || asset.OriginalOwner != owner || asset.TransferCount != 0 { t.Fatalf("owned asset = %+v", asset) } if err := asset.Validate(); err != nil { t.Fatalf("owned asset invariants: %v", err) } list := registryRows(t, pool, owner) if len(list) != 1 || list[0].Username != name || list[0].Editable || !list[0].Active || list[0].CollectibleID != asset.ID { t.Fatalf("registry = %+v", list) } replay, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key)) if err != nil || created || replay.ID != asset.ID || replay.Version != asset.Version { t.Fatalf("replay created=%v asset=%+v err=%v", created, replay, err) } var assets int if err := pool.QueryRow(ctx, `SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`, lowerASCII(name)).Scan(&assets); err != nil { t.Fatal(err) } if assets != 1 { t.Fatalf("replay must not mint again, got %d assets", assets) } // The same name cannot be minted twice, even under a different command key. if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, key+"-again")); !errors.Is(err, domain.ErrUsernameOccupied) { t.Fatalf("duplicate mint err = %v, want ErrUsernameOccupied", err) } } // TestCollectibleUsernameMintRejectsOccupiedEditableName proves the collectible // registry and the editable slot share one occupancy namespace. func TestCollectibleUsernameMintRejectsOccupiedEditableName(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 holder := collectibleTestUser(t, pool, 2_200_000_000+seed, "") name := fmt.Sprintf("taken%d", seed) setEditableUsername(t, pool, holder, name) cleanupCollectible(t, pool, lowerASCII(name)) if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, "")); !errors.Is(err, domain.ErrUsernameOccupied) { t.Fatalf("mint over editable name err = %v, want ErrUsernameOccupied", err) } if _, err := store.CollectibleUsername(ctx, name); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) { t.Fatalf("rejected mint must leave no asset, err = %v", err) } } // TestCollectibleUsernameTransferPreservesRecipientEditableSlot is the core // regression: moving an asset must not disturb either peer's editable username. func TestCollectibleUsernameTransferPreservesRecipientEditableSlot(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 from := collectibleTestUser(t, pool, 2_300_000_000+seed, "") to := collectibleTestUser(t, pool, 2_400_000_000+seed, "") fromEditable := fmt.Sprintf("sender%d", seed) toEditable := fmt.Sprintf("recip%d", seed) setEditableUsername(t, pool, from, fromEditable) setEditableUsername(t, pool, to, toEditable) name := fmt.Sprintf("moved%d", seed) cleanupCollectible(t, pool, lowerASCII(name)) asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, from, "")) if err != nil { t.Fatalf("mint: %v", err) } key := fmt.Sprintf("transfer-%d", seed) moved, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{ Username: name, To: to, Actor: "ops", Reason: "sold", CommandKey: key, }) if err != nil || !changed { t.Fatalf("transfer changed=%v err=%v", changed, err) } if moved.Owner != to || moved.OriginalOwner != from || moved.TransferCount != 1 || moved.Version != asset.Version+1 { t.Fatalf("transferred asset = %+v", moved) } fromList := registryRows(t, pool, from) if len(fromList) != 1 || fromList[0].Username != fromEditable || !fromList[0].Editable { t.Fatalf("sender registry = %+v, editable slot must survive", fromList) } toList := registryRows(t, pool, to) if len(toList) != 2 || toList[0].Username != toEditable || !toList[0].Editable || toList[1].Username != name || !toList[1].Collectible() { t.Fatalf("recipient registry = %+v", toList) } replay, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{ Username: name, To: to, CommandKey: key, }) if err != nil || changed || replay.Version != moved.Version { t.Fatalf("transfer replay changed=%v asset=%+v err=%v", changed, replay, err) } // Revoking back to the vault releases the recipient's registry row only. vaulted, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{ Username: name, Actor: "ops", Reason: "recalled", CommandKey: key + "-revoke", }) if err != nil || !changed { t.Fatalf("revoke changed=%v err=%v", changed, err) } if vaulted.Status != domain.CollectibleUsernameStatusVault || vaulted.Owned() || vaulted.OriginalOwner != from { t.Fatalf("revoked asset = %+v", vaulted) } toList = registryRows(t, pool, to) if len(toList) != 1 || toList[0].Username != toEditable { t.Fatalf("recipient registry after revoke = %+v", toList) } } // TestCollectibleUsernameBurnReleasesName covers a burn: the asset is retired and // the name stops resolving, so an ordinary peer can claim it as its editable slot. func TestCollectibleUsernameBurnReleasesName(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 holder := collectibleTestUser(t, pool, 2_500_000_000+seed, "") claimer := collectibleTestUser(t, pool, 2_600_000_000+seed, "") name := fmt.Sprintf("burned%d", seed) cleanupCollectible(t, pool, lowerASCII(name)) if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil { t.Fatalf("mint: %v", err) } burned, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{ Username: name, Burn: true, Actor: "ops", Reason: "abuse", }) if err != nil || !changed { t.Fatalf("burn changed=%v err=%v", changed, err) } if burned.Status != domain.CollectibleUsernameStatusBurned || burned.Owned() { t.Fatalf("burned asset = %+v", burned) } if list := registryRows(t, pool, holder); len(list) != 0 { t.Fatalf("holder registry after burn = %+v", list) } // The freed name is claimable as an ordinary editable username. setEditableUsername(t, pool, claimer, name) if list := registryRows(t, pool, claimer); len(list) != 1 || !list[0].Editable || list[0].Username != name { t.Fatalf("claimer registry = %+v", list) } // Every further mutation of a burned asset is refused. if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{ Username: name, To: holder, }); !errors.Is(err, domain.ErrCollectibleUsernameBurned) { t.Fatalf("transfer of burned asset err = %v, want ErrCollectibleUsernameBurned", err) } if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{ Username: name, Burn: true, }); !errors.Is(err, domain.ErrCollectibleUsernameBurned) { t.Fatalf("re-burn err = %v, want ErrCollectibleUsernameBurned", err) } if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{ Username: fmt.Sprintf("ghost%d", seed), To: holder, }); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) { t.Fatalf("transfer of unknown asset err = %v, want ErrCollectibleUsernameNotFound", err) } } // TestCollectibleUsernamePeerLimit covers the per-peer bound on both entry paths: // minting straight to the holder and transferring into it. func TestCollectibleUsernamePeerLimit(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 holder := collectibleTestUser(t, pool, 2_700_000_000+seed, "") prefix := fmt.Sprintf("lim%d", seed) t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM collectible_usernames WHERE username_lower LIKE $1 || '%'`, lowerASCII(prefix)) }) for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ { name := fmt.Sprintf("%sn%02d", prefix, i) if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil { t.Fatalf("mint %s: %v", name, err) } } if list := registryRows(t, pool, holder); len(list) != domain.MaxPeerCollectibleUsernames { t.Fatalf("registry size = %d", len(list)) } overflow := prefix + "over" if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(overflow, holder, "")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) { t.Fatalf("mint over limit err = %v, want ErrCollectibleUsernameLimit", err) } if _, err := store.CollectibleUsername(ctx, overflow); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) { t.Fatalf("rejected mint must not leave an asset: %v", err) } vaulted, _, err := store.MintCollectibleUsername(ctx, mintRequest(prefix+"vault", domain.Peer{}, "")) if err != nil { t.Fatalf("mint vault asset: %v", err) } if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{ Username: vaulted.Username, To: holder, }); !errors.Is(err, domain.ErrCollectibleUsernameLimit) { t.Fatalf("transfer over limit err = %v, want ErrCollectibleUsernameLimit", err) } // The refused transfer must not have released the asset from the vault. after, err := store.CollectibleUsername(ctx, vaulted.Username) if err != nil || after.Status != domain.CollectibleUsernameStatusVault { t.Fatalf("vault asset after refused transfer = %+v err=%v", after, err) } owned, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{ Owner: holder, Status: domain.CollectibleUsernameStatusOwned, Limit: 100, }) if err != nil || len(owned) != domain.MaxPeerCollectibleUsernames { t.Fatalf("list owned = %d err=%v", len(owned), err) } prefixed, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{ Query: prefix + "n0", Limit: 100, }) if err != nil || len(prefixed) != 10 { t.Fatalf("list by prefix = %d err=%v", len(prefixed), err) } } // TestCollectibleUsernameRegistryToggleAndReorder covers the registry-only // surface: activation, ordering and the bulk deactivation, none of which may // touch the editable slot. func TestCollectibleUsernameRegistryToggleAndReorder(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 holder := collectibleTestUser(t, pool, 2_800_000_000+seed, "") editable := fmt.Sprintf("edit%d", seed) setEditableUsername(t, pool, holder, editable) first := fmt.Sprintf("alpha%d", seed) second := fmt.Sprintf("beta%d", seed) cleanupCollectible(t, pool, lowerASCII(first)) cleanupCollectible(t, pool, lowerASCII(second)) for _, name := range []string{first, second} { if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil { t.Fatalf("mint %s: %v", name, err) } } changed, err := store.SetUsernameActive(ctx, holder, first, false) if err != nil || !changed { t.Fatalf("deactivate collectible changed=%v err=%v", changed, err) } if _, err := store.SetUsernameActive(ctx, holder, editable, false); !errors.Is(err, domain.ErrUsernameNotCollectible) { t.Fatalf("toggling the editable slot err = %v, want ErrUsernameNotCollectible", err) } // first is inactive now, so a client would send only the active names; the // editable slot is one of them and listing it is required, not rejected. changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second}) if err != nil || !changed { t.Fatalf("reorder changed=%v err=%v", changed, err) } list := registryRows(t, pool, holder) if len(list) != 3 || list[0].Username != editable || list[1].Username != second || list[2].Username != first { t.Fatalf("registry order = %+v", list) } // Re-sending the order the peer already has is a no-op a client can repeat. if changed, err := store.ReorderUsernames(ctx, holder, []string{editable, second}); err != nil || changed { t.Fatalf("repeat reorder changed=%v err=%v", changed, err) } // A collectible may be promoted above the editable slot, which is what makes // it the peer's primary username for clients. changed, err = store.ReorderUsernames(ctx, holder, []string{second, editable}) if err != nil || !changed { t.Fatalf("promote collectible changed=%v err=%v", changed, err) } list = registryRows(t, pool, holder) if len(list) != 3 || list[0].Username != second || list[1].Username != editable { t.Fatalf("registry order after promoting a collectible = %+v", list) } if domain.ActiveUsername(list) != second { t.Fatalf("active username after promoting a collectible = %q, want %q", domain.ActiveUsername(list), second) } changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second}) if err != nil || !changed { t.Fatalf("restore order changed=%v err=%v", changed, err) } // An order that omits an active username is still rejected, and so is one // naming something the peer does not own. if _, err := store.ReorderUsernames(ctx, holder, []string{second}); !errors.Is(err, domain.ErrUsernameOrderInvalid) { t.Fatalf("partial reorder err = %v, want ErrUsernameOrderInvalid", err) } if _, err := store.ReorderUsernames(ctx, holder, []string{editable, second, "nobodyowns" + editable}); !errors.Is(err, domain.ErrUsernameOrderInvalid) { t.Fatalf("reorder with a foreign name err = %v, want ErrUsernameOrderInvalid", err) } changed, err = store.DeactivateAllUsernames(ctx, holder) if err != nil || !changed { t.Fatalf("deactivate all changed=%v err=%v", changed, err) } list = registryRows(t, pool, holder) if len(list) != 3 || !list[0].Active || list[1].Active || list[2].Active { t.Fatalf("registry after deactivate all = %+v", list) } batch, err := store.PeerUsernamesBatch(ctx, []domain.Peer{holder, {Type: domain.PeerTypeUser, ID: holder.ID + 1}}) if err != nil || len(batch[holder]) != 3 { t.Fatalf("batch = %+v err=%v", batch, err) } if domain.ActiveUsername(batch[holder]) != editable { t.Fatalf("active username = %q", domain.ActiveUsername(batch[holder])) } } // TestCollectibleUsernameEditableEditKeepsCollectibles pins the peer_username.go // surgery: rewriting or clearing the editable slot must leave collectible rows // and their assets untouched. func TestCollectibleUsernameEditableEditKeepsCollectibles(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 holder := collectibleTestUser(t, pool, 2_900_000_000+seed, "") name := fmt.Sprintf("keep%d", seed) cleanupCollectible(t, pool, lowerASCII(name)) asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")) if err != nil { t.Fatalf("mint: %v", err) } setEditableUsername(t, pool, holder, fmt.Sprintf("one%d", seed)) setEditableUsername(t, pool, holder, fmt.Sprintf("two%d", seed)) setEditableUsername(t, pool, holder, "") list := registryRows(t, pool, holder) if len(list) != 1 || list[0].CollectibleID != asset.ID { t.Fatalf("registry after editable churn = %+v", list) } stored, err := store.CollectibleUsernameByID(ctx, asset.ID) if err != nil || stored.Owner != holder { t.Fatalf("asset after editable churn = %+v err=%v", stored, err) } // The editable slot may not duplicate a name the peer holds as a collectible. tx, err := pool.Begin(ctx) if err != nil { t.Fatal(err) } defer func() { _ = tx.Rollback(ctx) }() if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, holder.ID, name, lowerASCII(name)); !errors.Is(err, domain.ErrUsernameOccupied) { t.Fatalf("editable slot over own collectible err = %v, want ErrUsernameOccupied", err) } } // TestCollectibleUsernameReissueAfterBurn covers migration 0152: uniqueness now // spans live assets only, so a burned name can be issued again while its burned // rows remain as provenance. func TestCollectibleUsernameReissueAfterBurn(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 holder := collectibleTestUser(t, pool, 3_500_000_000+seed, "") name := fmt.Sprintf("reissue%d", seed) cleanupCollectible(t, pool, lowerASCII(name)) first, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")) if err != nil { t.Fatalf("first mint: %v", err) } if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{ Username: name, Burn: true, Actor: "ops", Reason: "retire", }); err != nil { t.Fatalf("burn: %v", err) } second, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")) if err != nil || !created { t.Fatalf("reissue: created=%v err=%v", created, err) } if second.ID == first.ID { t.Fatalf("reissue reused asset id %d", second.ID) } // Both rows coexist: the live one is what the name resolves to. live, err := store.CollectibleUsername(ctx, name) if err != nil || live.ID != second.ID { t.Fatalf("lookup after reissue = %+v err=%v, want live asset %d", live, err, second.ID) } burned, err := store.CollectibleUsernameByID(ctx, first.ID) if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned { t.Fatalf("burned provenance row = %+v err=%v", burned, err) } var rowCount int if err := pool.QueryRow(ctx, `SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`, lowerASCII(name)).Scan(&rowCount); err != nil { t.Fatalf("count rows: %v", err) } if rowCount != 2 { t.Fatalf("rows for reissued name = %d, want 2", rowCount) } // The live asset still blocks another mint. if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); !errors.Is(err, domain.ErrUsernameOccupied) { t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err) } if list := registryRows(t, pool, holder); len(list) != 1 || list[0].CollectibleID != second.ID { t.Fatalf("holder registry after reissue = %+v", list) } } // TestCollectibleUsernameDelete covers the hard delete: asset, registry row and // provenance all disappear and the name becomes fully free. func TestCollectibleUsernameDelete(t *testing.T) { pool := testPool(t) ctx := context.Background() store := NewCollectibleUsernameStore(pool) seed := time.Now().UnixNano() % 1_000_000 holder := collectibleTestUser(t, pool, 3_600_000_000+seed, "") name := fmt.Sprintf("mistake%d", seed) cleanupCollectible(t, pool, lowerASCII(name)) editable := fmt.Sprintf("keep%d", seed) setEditableUsername(t, pool, holder, editable) asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")) if err != nil { t.Fatalf("mint: %v", err) } deleted, err := store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{ Username: "@" + name, Actor: "ops", Reason: "issued by mistake", }) if err != nil || !deleted { t.Fatalf("delete: deleted=%v err=%v", deleted, err) } if _, err := store.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) { t.Fatalf("asset after delete err = %v, want not found", err) } // ON DELETE CASCADE took the provenance rows with the asset. var transfers int if err := pool.QueryRow(ctx, `SELECT count(*) FROM collectible_username_transfers WHERE collectible_id = $1`, asset.ID).Scan(&transfers); err != nil { t.Fatalf("count transfers: %v", err) } if transfers != 0 { t.Fatalf("provenance rows after delete = %d, want 0", transfers) } // The holder keeps its editable slot and loses only the collectible row. list := registryRows(t, pool, holder) if len(list) != 1 || !list[0].Editable || list[0].Username != editable { t.Fatalf("holder registry after delete = %+v", list) } // The name is free again, with no burned history left behind. if _, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil || !created { t.Fatalf("mint after delete: created=%v err=%v", created, err) } // Deleting a name that has no live asset is a no-op, not an error. if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{ Username: name, Burn: true, Actor: "ops", Reason: "retire", }); err != nil { t.Fatalf("burn before repeat delete: %v", err) } deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{ Username: name, Actor: "ops", Reason: "again", }) if err != nil || deleted { t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err) } deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{ Username: fmt.Sprintf("absent%d", seed), Actor: "ops", Reason: "again", }) if err != nil || deleted { t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err) } }