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:
Egor Egorov 2026-07-27 20:18:00 +03:00 • committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -0,0 +1,392 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes for the rating reads, so an unset limit resolves to a
// finite page the way the PostgreSQL LIMIT does.
const (
defaultAccountRatingListLimit = 50
defaultAccountRatingEventLimit = 50
defaultAccountRatingStaleLimit = 50
)
// AccountRatingStore is the in-memory implementation of store.AccountRatingStore.
// It reproduces the invariants migration 0151 encodes:
//
// - account_rating is keyed by user_id, so one projection row per user.
// - the version CHECK plus optimistic concurrency: a write is applied only when
// it carries the successor of the stored version, which is exactly what
// domain.ResolveAccountRatingPending produces.
// - the pending pair CHECK: a pending delta and its date exist together or not
// at all.
// - the component CHECKs: stars/activity/penalty components and level are
// non-negative, and next_level_stars is either absent or above
// current_level_stars.
// - account_rating_events_command_idx: a replayed command key never appends a
// second adjustment.
type AccountRatingStore struct {
mu sync.Mutex
nextID int64
// ratings is the account_rating read model.
ratings map[int64]domain.AccountRating
// events is the append-only contribution ledger in insertion order.
events []domain.AccountRatingEvent
// commands maps an adjustment command key onto the ledger row it created.
commands map[string]int64
// signals holds the raw contribution snapshot per user.
//
// PostgreSQL aggregates it from stars_transactions, message counts, saved
// gifts and moderation cases. In memory those live in unrelated store types
// (StarsStore, MessageStore, StarGiftStore, ModerationReportStore) that this
// store has no handle on, and wiring them in would make the rating depend on
// which stores a test happens to construct. The snapshot is therefore
// injected -- deterministic, and identical for a unit test and for the
// recompute worker, which is what domain.AccountRatingSignals promises. Only
// the manual total is derived here, from the ledger, because the ledger is
// this store's own data.
signals map[int64]domain.AccountRatingSignals
// accounts is the account universe UnratedAccounts seeds from, in declaration
// order.
//
// PostgreSQL reads it from the users table. This store has no users table and
// inventing one from whichever ids happen to appear in the ledger would be
// circular -- an account with no rating and no adjustment is exactly the case
// seeding exists for. So the universe is declared, like signals above.
accounts []int64
}
// NewAccountRatingStore creates an empty rating store.
func NewAccountRatingStore() *AccountRatingStore {
return &AccountRatingStore{
nextID: 1,
ratings: make(map[int64]domain.AccountRating),
commands: make(map[string]int64),
signals: make(map[int64]domain.AccountRatingSignals),
}
}
// SeedAccountRatingSignals installs raw contribution snapshots for tests in other
// packages; see the signals field for why they are injected rather than derived.
func (s *AccountRatingStore) SeedAccountRatingSignals(signals ...domain.AccountRatingSignals) {
for _, item := range signals {
s.setAccountRatingSignals(item)
}
}
// setAccountRatingSignals is the same hook for this package's tests.
func (s *AccountRatingStore) setAccountRatingSignals(signals domain.AccountRatingSignals) {
if signals.UserID <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.signals[signals.UserID] = signals
}
// AccountRating returns the stored projection, or domain.ErrAccountRatingNotFound
// when the user was never computed.
func (s *AccountRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
s.mu.Lock()
defer s.mu.Unlock()
rating, ok := s.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
// AccountRatingBatch resolves several users at once; users without a row are
// absent from the map.
func (s *AccountRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
out := make(map[int64]domain.AccountRating, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if rating, ok := s.ratings[userID]; ok {
out[userID] = rating
}
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// incoming Version must be the successor of the stored one, which is what
// domain.ResolveAccountRatingPending computes. A stale or missing version leaves
// the stored row untouched and reports changed=false, so a caller that lost a
// race can re-read and retry.
func (s *AccountRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if rating.UserID <= 0 {
// account_rating.user_id references users(id): there is no row to write.
return domain.AccountRating{}, false, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
stored, exists := s.ratings[rating.UserID]
if rating.Version != stored.Version+1 {
if !exists {
return domain.AccountRating{}, false, nil
}
return stored, false, nil
}
next := normalizeAccountRating(rating)
s.ratings[next.UserID] = next
return next, true, nil
}
// AccountRatingSignals returns the injected raw snapshot with the manual total
// taken from the ledger, mirroring the PostgreSQL aggregate. A user with no
// contributions reports zeros rather than an error, because the aggregate has no
// "missing row" state.
func (s *AccountRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
signals := s.signals[userID]
signals.UserID = userID
signals.Manual += s.manualTotalLocked(userID)
return signals, nil
}
// AdjustAccountRating appends a manual adjustment. A replayed command key returns
// the recorded event with applied=false and appends nothing.
func (s *AccountRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if req.CommandKey != "" {
if id, ok := s.commands[req.CommandKey]; ok {
for _, event := range s.events {
if event.ID == id {
return event, false, nil
}
}
}
}
event := domain.AccountRatingEvent{
ID: s.nextID,
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
s.nextID++
s.events = append(s.events, event)
if event.CommandKey != "" {
s.commands[event.CommandKey] = event.ID
}
return event, true, nil
}
// ListAccountRatings is the admin leaderboard: level desc, stars desc, user id
// asc, matching account_rating_leaderboard_idx. BeforeID is the keyset cursor and
// names the last row of the previous page.
func (s *AccountRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
s.mu.Lock()
defer s.mu.Unlock()
cursor, hasCursor := s.ratings[filter.BeforeID]
out := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if filter.MinLevel > 0 && rating.Level < filter.MinLevel {
continue
}
if filter.UserID > 0 && rating.UserID != filter.UserID {
continue
}
switch {
case filter.BeforeID <= 0:
case hasCursor:
// Keyset paging over the leaderboard order.
if !accountRatingLess(cursor, rating) {
continue
}
default:
// The cursor row is gone; fall back to the id tiebreak alone so paging
// still terminates.
if rating.UserID <= filter.BeforeID {
continue
}
}
out = append(out, rating)
}
sort.Slice(out, func(i, j int) bool { return accountRatingLess(out[i], out[j]) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first.
func (s *AccountRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if limit <= 0 {
limit = defaultAccountRatingEventLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AccountRatingEvent, 0, limit)
for i := len(s.events) - 1; i >= 0 && len(out) < limit; i-- {
if s.events[i].UserID != userID {
continue
}
out = append(out, s.events[i])
}
return out, nil
}
// StaleAccountRatings returns the users whose projection predates the horizon,
// oldest first, which is the order account_rating_stale_idx serves.
func (s *AccountRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
horizon := time.Unix(olderThanUnix, 0).UTC()
s.mu.Lock()
defer s.mu.Unlock()
stale := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if rating.ComputedAt.Before(horizon) {
stale = append(stale, rating)
}
}
sort.Slice(stale, func(i, j int) bool {
if !stale[i].ComputedAt.Equal(stale[j].ComputedAt) {
return stale[i].ComputedAt.Before(stale[j].ComputedAt)
}
return stale[i].UserID < stale[j].UserID
})
if len(stale) > limit {
stale = stale[:limit]
}
out := make([]int64, 0, len(stale))
for _, rating := range stale {
out = append(out, rating.UserID)
}
return out, nil
}
// SeedAccounts declares the account universe UnratedAccounts walks. Repeating an
// id is a no-op, so a test can declare accounts as it creates them.
func (s *AccountRatingStore) SeedAccounts(userIDs ...int64) {
s.mu.Lock()
defer s.mu.Unlock()
known := make(map[int64]struct{}, len(s.accounts))
for _, id := range s.accounts {
known[id] = struct{}{}
}
for _, id := range userIDs {
if id <= 0 {
continue
}
if _, ok := known[id]; ok {
continue
}
known[id] = struct{}{}
s.accounts = append(s.accounts, id)
}
}
// UnratedAccounts returns declared accounts that have no projection yet, in
// declaration order -- the memory stand-in for PostgreSQL's oldest-account-first
// walk. A store nobody seeded reports no candidates rather than erroring: the
// worker treats that as "nothing to seed", which is the truth.
func (s *AccountRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]int64, 0, limit)
for _, id := range s.accounts {
if _, rated := s.ratings[id]; rated {
continue
}
out = append(out, id)
if len(out) == limit {
break
}
}
return out, nil
}
// manualTotalLocked sums the manual ledger rows, the only kind that survives a
// recompute.
func (s *AccountRatingStore) manualTotalLocked(userID int64) int64 {
var total int64
for _, event := range s.events {
if event.UserID == userID && event.Kind == domain.AccountRatingEventManual {
total += event.Amount
}
}
return total
}
// normalizeAccountRating makes the rows the table's CHECK constraints forbid
// unrepresentable. PostgreSQL raises an opaque constraint error there rather than
// a domain error, so the memory store folds the impossible shapes onto the
// closest representable one instead of inventing an error the RPC layer would
// then have to handle only in tests.
func normalizeAccountRating(rating domain.AccountRating) domain.AccountRating {
if rating.Level < 0 {
rating.Level = 0
}
if rating.Level > domain.MaxAccountRatingLevel {
rating.Level = domain.MaxAccountRatingLevel
}
if rating.CurrentLevelStars < 0 {
rating.CurrentLevelStars = 0
}
if rating.StarsComponent < 0 {
rating.StarsComponent = 0
}
if rating.ActivityComponent < 0 {
rating.ActivityComponent = 0
}
if rating.PenaltyComponent < 0 {
rating.PenaltyComponent = 0
}
if !rating.HasNextLevel || rating.NextLevelStars <= rating.CurrentLevelStars {
rating.HasNextLevel = false
rating.NextLevelStars = 0
}
// The pending delta and its date only exist together.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
return rating
}
// accountRatingLess is the leaderboard order: highest level first, then the
// larger score, then the lower user id as a stable tiebreak.
func accountRatingLess(a, b domain.AccountRating) bool {
if a.Level != b.Level {
return a.Level > b.Level
}
if a.Stars != b.Stars {
return a.Stars > b.Stars
}
return a.UserID < b.UserID
}

View file

@ -0,0 +1,425 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
// accountRatingFixture builds a projection the table's CHECK constraints accept.
func accountRatingFixture(userID, stars, version int64, computedAt time.Time) domain.AccountRating {
level, current, next, hasNext := domain.AccountRatingLevelForStars(stars)
return domain.AccountRating{
UserID: userID,
Level: level,
Stars: stars,
CurrentLevelStars: current,
NextLevelStars: next,
HasNextLevel: hasNext,
StarsComponent: stars,
ComputedAt: computedAt,
UpdatedAt: computedAt,
Version: version,
}
}
func TestSaveAccountRating(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
tests := []struct {
name string
seed []domain.AccountRating
input domain.AccountRating
wantErr error
wantChanged bool
check func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating)
}{
{
name: "insert",
input: accountRatingFixture(11, 450, 1, now),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 2 || stored.Stars != 450 || stored.Version != 1 ||
stored.CurrentLevelStars != 400 || stored.NextLevelStars != 900 || !stored.HasNextLevel {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read != stored {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "successor version applied",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 1000, 2, now.Add(time.Minute)),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Version != 2 || stored.Stars != 1000 || stored.Level != 3 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "replayed version is stale",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 1, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
// The loser of the race gets the current row back, untouched.
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read.Stars != 450 || read.Version != 1 {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "version from the future is rejected",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 7, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "insert must carry version one",
input: accountRatingFixture(11, 450, 3, now),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored != (domain.AccountRating{}) {
t.Fatalf("stored=%+v", stored)
}
if _, err := s.AccountRating(ctx, 11); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("row was written: %v", err)
}
},
},
{
name: "no user",
input: accountRatingFixture(0, 450, 1, now),
wantErr: domain.ErrAccountRatingNotFound,
},
{
name: "pending delta without a date is dropped",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 120
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.PendingStars != 0 || !stored.PendingDate.IsZero() {
t.Fatalf("stored=%+v", stored)
}
if _, ok := stored.PendingLevel(); ok {
t.Fatalf("pending projection survived: %+v", stored)
}
},
},
{
name: "pending pair is kept",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 500
rating.PendingDate = now.Add(time.Hour)
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
pending, ok := stored.PendingLevel()
if !ok || pending.Stars != 950 || pending.Level != 3 {
t.Fatalf("pending=%+v ok=%v", pending, ok)
}
},
},
{
name: "impossible components are folded",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.Level = -3
rating.StarsComponent = -10
rating.ActivityComponent = -1
rating.PenaltyComponent = -7
rating.CurrentLevelStars = -5
rating.NextLevelStars = -9
rating.HasNextLevel = true
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 0 || stored.StarsComponent != 0 || stored.ActivityComponent != 0 ||
stored.PenaltyComponent != 0 || stored.CurrentLevelStars != 0 {
t.Fatalf("stored=%+v", stored)
}
if stored.HasNextLevel || stored.NextLevelStars != 0 {
t.Fatalf("next level survived: %+v", stored)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewAccountRatingStore()
for _, seed := range tc.seed {
if _, changed, err := s.SaveAccountRating(ctx, seed); err != nil || !changed {
t.Fatalf("seed changed=%v err=%v", changed, err)
}
}
stored, changed, err := s.SaveAccountRating(ctx, tc.input)
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.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, stored)
}
})
}
}
func TestAccountRatingReadsAndLeaderboard(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 2500, 1, now),
accountRatingFixture(12, 450, 1, now),
accountRatingFixture(13, 2500, 1, now),
accountRatingFixture(14, 0, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
batch, err := s.AccountRatingBatch(ctx, []int64{11, 13, 99, 0, 11})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if batch[11].Stars != 2500 || batch[13].Stars != 2500 {
t.Fatalf("batch=%+v", batch)
}
if empty, err := s.AccountRatingBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch=%+v err=%v", empty, err)
}
// level desc, stars desc, user id asc.
board, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{})
if err != nil || len(board) != 4 {
t.Fatalf("board=%+v err=%v", board, err)
}
want := []int64{11, 13, 12, 14}
for i, userID := range want {
if board[i].UserID != userID {
t.Fatalf("board order=%+v want %v", board, want)
}
}
page, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{Limit: 2})
if err != nil || len(page) != 2 || page[1].UserID != 13 {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{
BeforeID: page[len(page)-1].UserID, Limit: 2,
})
if err != nil || len(next) != 2 || next[0].UserID != 12 || next[1].UserID != 14 {
t.Fatalf("next=%+v err=%v", next, err)
}
filtered, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 5})
if err != nil || len(filtered) != 2 {
t.Fatalf("filtered=%+v err=%v", filtered, err)
}
single, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: 12})
if err != nil || len(single) != 1 || single[0].UserID != 12 {
t.Fatalf("single=%+v err=%v", single, err)
}
if _, err := s.AccountRating(ctx, 99); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("unknown user err=%v", err)
}
}
func TestAdjustAccountRating(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
req := domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 750, Reason: "contest prize", Actor: "admin", CommandKey: "cmd-adjust",
}
event, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.Reason != "contest prize" || event.Actor != "admin" || event.CreatedAt.IsZero() {
t.Fatalf("event=%+v", event)
}
// Replaying the command key returns the recorded row and appends nothing.
replay, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || applied {
t.Fatalf("replay applied=%v err=%v", applied, err)
}
if replay != event {
t.Fatalf("replay=%+v want %+v", replay, event)
}
ledger, err := s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 1 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
second := req
second.Amount = -200
second.CommandKey = "cmd-adjust-2"
if _, applied, err := s.AdjustAccountRating(ctx, second); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
// Newest first, and other users are not mixed in.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 12, Amount: 5, CommandKey: "cmd-other",
}); err != nil || !applied {
t.Fatalf("other user applied=%v err=%v", applied, err)
}
ledger, err = s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 2 || ledger[0].Amount != -200 || ledger[1].Amount != 750 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
if capped, err := s.AccountRatingEvents(ctx, 11, 1); err != nil || len(capped) != 1 ||
capped[0].Amount != -200 {
t.Fatalf("capped=%+v err=%v", capped, err)
}
// An unkeyed adjustment is always appended.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 10,
}); err != nil || !applied {
t.Fatalf("unkeyed applied=%v err=%v", applied, err)
}
for _, invalid := range []domain.AdjustAccountRatingRequest{
{UserID: 11, Amount: 0, CommandKey: "cmd-zero"},
{UserID: 0, Amount: 5, CommandKey: "cmd-nouser"},
} {
if _, applied, err := s.AdjustAccountRating(ctx, invalid); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) || applied {
t.Fatalf("invalid adjust applied=%v err=%v", applied, err)
}
}
// The manual total is carried out of the ledger into the signal snapshot.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals.UserID != 11 || signals.Manual != 560 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
}
func TestAccountRatingSignals(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
// A user with no contributions reports zeros rather than an error.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals != (domain.AccountRatingSignals{UserID: 11}) {
t.Fatalf("signals=%+v err=%v", signals, err)
}
if _, err := s.AccountRatingSignals(ctx, 0); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing user err=%v", err)
}
s.setAccountRatingSignals(domain.AccountRatingSignals{
UserID: 11, StarsReceived: 4000, StarsSpent: 2000, MessagesSent: 300,
AccountAgeDays: 100, GiftsReceived: 4, ModerationCases: 1,
})
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 250, CommandKey: "cmd-bonus",
}); err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil || signals.StarsReceived != 4000 || signals.MessagesSent != 300 ||
signals.ModerationCases != 1 || signals.Manual != 250 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
// The snapshot feeds the domain formula, and the result round-trips.
now := time.Unix(1700000000, 0).UTC()
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := s.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("save changed=%v err=%v", changed, err)
}
if stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.ManualComponent != 250 {
t.Fatalf("stored=%+v computed=%+v", stored, computed)
}
// A recompute uses the pending resolution the domain owns.
s.setAccountRatingSignals(domain.AccountRatingSignals{UserID: 11, StarsReceived: 40000})
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil {
t.Fatal(err)
}
recomputed := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now.Add(time.Hour)),
24*time.Hour, now.Add(time.Hour))
saved, changed, err := s.SaveAccountRating(ctx, recomputed)
if err != nil || !changed || saved.Version != stored.Version+1 {
t.Fatalf("saved=%+v changed=%v err=%v", saved, changed, err)
}
if saved.PendingStars <= 0 || saved.PendingDate.IsZero() {
t.Fatalf("pending was not parked: %+v", saved)
}
}
func TestStaleAccountRatings(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 100, 1, now.Add(-3*time.Hour)),
accountRatingFixture(12, 100, 1, now.Add(-2*time.Hour)),
accountRatingFixture(13, 100, 1, now.Add(-time.Hour)),
accountRatingFixture(14, 100, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
stale, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 2 || stale[0] != 11 || stale[1] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
if limited, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 1); err != nil ||
len(limited) != 1 || limited[0] != 11 {
t.Fatalf("limited=%v err=%v", limited, err)
}
if none, err := s.StaleAccountRatings(ctx, now.Add(-4*time.Hour).Unix(), 10); err != nil || len(none) != 0 {
t.Fatalf("none=%v err=%v", none, err)
}
// A recompute refreshes computed_at and takes the row out of the horizon.
refreshed := accountRatingFixture(11, 100, 2, now)
if _, changed, err := s.SaveAccountRating(ctx, refreshed); err != nil || !changed {
t.Fatalf("refresh changed=%v err=%v", changed, err)
}
stale, err = s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 1 || stale[0] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,851 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
func botVerificationUserPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeUser, ID: id}
}
func botVerificationChannelPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeChannel, ID: id}
}
// botVerificationTestVerifier grants verifier status the way the admin edge does.
func botVerificationTestVerifier(t *testing.T, s *BotVerificationStore, botID, iconDocumentID int64) domain.BotVerifierSettings {
t.Helper()
settings, err := s.UpsertBotVerifierSettings(context.Background(), domain.BotVerifierSettings{
BotID: botID,
IconDocumentID: iconDocumentID,
CompanyName: fmt.Sprintf("Verifier %d", botID),
DefaultDescription: "verified by the test verifier",
CanModifyCustomDescription: true,
Enabled: true,
GrantedBy: "operator",
GrantReason: "test fixture",
})
if err != nil {
t.Fatalf("grant verifier %d: %v", botID, err)
}
return settings
}
// botVerificationTestRequest is an application that clears domain validation.
func botVerificationTestRequest(verifier, applicant int64, peer domain.Peer, username string) domain.CustomVerificationRequest {
return domain.CustomVerificationRequest{
VerifierBotID: verifier,
ApplicantUserID: applicant,
Peer: peer,
PeerTitle: "Target " + username,
PeerUsername: username,
Reason: "we run the official account for this brand",
RequestedDescription: "official brand account",
CorrelationID: fmt.Sprintf("corr-%d", peer.ID),
}
}
// TestBotVerificationIconCatalogueMemory covers the catalogue: an entry is keyed
// by document id, retiring one keeps it readable, and the listing pages newest
// first with the activeOnly filter honoured.
func TestBotVerificationIconCatalogueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
first, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, Name: " Blue check ", Active: true,
})
if err != nil {
t.Fatalf("upsert icon: %v", err)
}
if first.ID == 0 || first.Name != "Blue check" || !first.Active || first.OwnerBotID != 0 {
t.Fatalf("stored icon = %+v", first)
}
if first.CreatedAt.IsZero() || first.UpdatedAt.Before(first.CreatedAt) {
t.Fatalf("icon timestamps = %v / %v", first.CreatedAt, first.UpdatedAt)
}
second, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5002, OwnerBotID: 777, Name: "Reserved", Active: true,
})
if err != nil {
t.Fatalf("upsert second icon: %v", err)
}
// document_id is the identity of an entry: the second upsert edits in place.
edited, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, OwnerBotID: 42, Name: "Blue check v2", Active: true,
})
if err != nil {
t.Fatalf("re-upsert icon: %v", err)
}
if edited.ID != first.ID || edited.Name != "Blue check v2" || edited.OwnerBotID != 42 {
t.Fatalf("edited icon = %+v, want id %d", edited, first.ID)
}
if !edited.CreatedAt.Equal(first.CreatedAt) || !edited.UpdatedAt.After(first.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 0, Name: "bad"}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without document err = %v, want ErrVerificationIconInvalid", err)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 9, Name: " "}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without name err = %v, want ErrVerificationIconInvalid", err)
}
retired, err := s.SetVerificationIconActive(ctx, second.ID, false)
if err != nil {
t.Fatalf("retire icon: %v", err)
}
if retired.Active || retired.ID != second.ID {
t.Fatalf("retired icon = %+v", retired)
}
if _, err := s.SetVerificationIconActive(ctx, second.ID+1000, false); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("retire unknown err = %v, want ErrVerificationIconNotFound", err)
}
byDocument, err := s.VerificationIconByDocument(ctx, 5002)
if err != nil {
t.Fatalf("read icon by document: %v", err)
}
if byDocument.ID != second.ID || byDocument.Active {
t.Fatalf("icon by document = %+v", byDocument)
}
if _, err := s.VerificationIconByDocument(ctx, 999999); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("unknown document err = %v, want ErrVerificationIconNotFound", err)
}
byID, err := s.VerificationIcon(ctx, first.ID)
if err != nil || byID.DocumentID != 5001 {
t.Fatalf("read icon by id = %+v err=%v", byID, err)
}
if _, err := s.VerificationIcon(ctx, 0); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("icon id 0 err = %v, want ErrVerificationIconNotFound", err)
}
all, err := s.ListVerificationIcons(ctx, false, 0)
if err != nil {
t.Fatalf("list icons: %v", err)
}
if len(all) != 2 || all[0].ID != second.ID || all[1].ID != first.ID {
t.Fatalf("catalogue order = %+v, want newest first", all)
}
active, err := s.ListVerificationIcons(ctx, true, 0)
if err != nil {
t.Fatalf("list active icons: %v", err)
}
if len(active) != 1 || active[0].ID != first.ID {
t.Fatalf("active catalogue = %+v", active)
}
if page, err := s.ListVerificationIcons(ctx, false, 1); err != nil || len(page) != 1 {
t.Fatalf("icon page = %+v err=%v", page, err)
}
}
// TestBotVerifierSettingsLifecycleMemory covers verifier status: optimistic
// locking on version, the idempotent kill switch and the cascade that takes the
// marks with the verifier row.
func TestBotVerifierSettingsLifecycleMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
if _, err := s.BotVerifierSettings(ctx, 4242); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("unknown verifier err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 7, CompanyName: "Acme", Version: 3,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("versioned upsert of missing row err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 0, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrVerifierSettingsInvalid) {
t.Fatalf("iconless upsert err = %v, want ErrVerifierSettingsInvalid", err)
}
created := botVerificationTestVerifier(t, s, 4242, 5001)
if created.Version != 1 || !created.Enabled || created.CompanyName != "Verifier 4242" {
t.Fatalf("created verifier = %+v", created)
}
// Version 0 means "there is no row yet", so it loses against the stored row.
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("re-create err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme", Version: 99,
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale upsert err = %v, want ErrCustomVerificationVersionConflict", err)
}
edited, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242,
IconDocumentID: 5002,
CompanyName: "Acme Media",
DefaultDescription: "checked by Acme",
Enabled: true,
Version: created.Version,
})
if err != nil {
t.Fatalf("edit verifier: %v", err)
}
if edited.Version != 2 || edited.IconDocumentID != 5002 || edited.CanModifyCustomDescription {
t.Fatalf("edited verifier = %+v", edited)
}
if !edited.CreatedAt.Equal(created.CreatedAt) || !edited.UpdatedAt.After(created.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
disabled, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("disable verifier: %v", err)
}
if disabled.Enabled || disabled.Version != edited.Version+1 {
t.Fatalf("disabled verifier = %+v", disabled)
}
again, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("re-disable verifier: %v", err)
}
if again.Version != disabled.Version {
t.Fatalf("re-disable bumped version to %d", again.Version)
}
if _, err := s.SetBotVerifierEnabled(ctx, 777777, false); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("disable unknown err = %v, want ErrVerifierNotFound", err)
}
// A disabled verifier is still readable: the admin panel renders the switch.
stored, err := s.BotVerifierSettings(ctx, 4242)
if err != nil || stored.Enabled {
t.Fatalf("read disabled verifier = %+v err=%v", stored, err)
}
other := botVerificationTestVerifier(t, s, 1042, 5001)
batch, err := s.BotVerifierSettingsBatch(ctx, []int64{4242, 1042, 999999, 0})
if err != nil {
t.Fatalf("batch verifiers: %v", err)
}
if len(batch) != 2 || batch[4242].Enabled || !batch[1042].Enabled {
t.Fatalf("verifier batch = %+v", batch)
}
if _, absent := batch[999999]; absent {
t.Fatal("batch invented a verifier")
}
listed, err := s.ListBotVerifiers(ctx, false, 0)
if err != nil {
t.Fatalf("list verifiers: %v", err)
}
if len(listed) != 2 || listed[0].BotID != other.BotID || listed[1].BotID != 4242 {
t.Fatalf("verifier list = %+v, want bot id order", listed)
}
enabledOnly, err := s.ListBotVerifiers(ctx, true, 0)
if err != nil {
t.Fatalf("list enabled verifiers: %v", err)
}
if len(enabledOnly) != 1 || enabledOnly[0].BotID != other.BotID {
t.Fatalf("enabled verifier list = %+v", enabledOnly)
}
// Marks cascade with the verifier row; applications do not.
peer := botVerificationChannelPeer(9001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: other.BotID, Peer: peer, Description: "cascade me",
}); err != nil {
t.Fatalf("grant before delete: %v", err)
}
req, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(other.BotID, 31337, peer, "CascadeChannel"))
if err != nil {
t.Fatalf("create request before delete: %v", err)
}
removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID)
if err != nil || !removed {
t.Fatalf("delete verifier: removed=%v err=%v", removed, err)
}
if removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID); err != nil || removed {
t.Fatalf("repeated delete: removed=%v err=%v", removed, err)
}
if _, err := s.CustomVerification(ctx, other.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after cascade err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, other.BotID); err != nil || count != 0 {
t.Fatalf("mark count after cascade = %d err=%v", count, err)
}
if kept, err := s.CustomVerificationRequest(ctx, req.ID); err != nil || kept.ID != req.ID {
t.Fatalf("application after cascade = %+v err=%v", kept, err)
}
}
// TestCustomVerificationGrantAndProjectionMemory is the projection contract:
// exactly one mark exists per peer, a later verifier replaces the former one,
// a disabled verifier projects nothing while its row survives, and a repeated
// grant updates the mark in place.
func TestCustomVerificationGrantAndProjectionMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
alpha := botVerificationTestVerifier(t, s, 101, 5001)
beta := botVerificationTestVerifier(t, s, 102, 5002)
peer := botVerificationUserPeer(7001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: 999, Peer: peer,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("grant by non-verifier err = %v, want ErrVerifierNotFound", err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("grant on community err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// The icon is denormalised from the verifier when the caller leaves it unset.
alphaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, Description: "checked by alpha",
GrantedByUserID: 555,
})
if err != nil || !created {
t.Fatalf("grant alpha mark: created=%v err=%v", created, err)
}
if alphaMark.IconDocumentID != alpha.IconDocumentID || alphaMark.Version != 1 {
t.Fatalf("alpha mark = %+v", alphaMark)
}
if alphaMark.CreatedAt.IsZero() || !alphaMark.UpdatedAt.Equal(alphaMark.CreatedAt) {
t.Fatalf("alpha mark timestamps = %v / %v", alphaMark.CreatedAt, alphaMark.UpdatedAt)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != alphaMark.ID {
t.Fatalf("projection with one mark = %+v err=%v", got, err)
}
betaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: peer, Description: "checked by beta",
})
if err != nil || !created {
t.Fatalf("grant beta mark: created=%v err=%v", created, err)
}
if betaMark.ID != alphaMark.ID || betaMark.Version != alphaMark.Version+1 {
t.Fatalf("replacement mark = %+v, want id %d v%d", betaMark, alphaMark.ID, alphaMark.Version+1)
}
if _, err := s.CustomVerification(ctx, alpha.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("replaced alpha mark err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 0 {
t.Fatalf("alpha mark count after replacement = %d err=%v", count, err)
}
// One peer has one wire-visible mark, irrespective of how often it is read.
for i := 0; i < 3; i++ {
got, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after replacement: %v", err)
}
if got.ID != betaMark.ID || got.VerifierBotID != beta.BotID {
t.Fatalf("projection = %+v, want replacement mark %d", got, betaMark.ID)
}
if got.Projection().Icon != beta.IconDocumentID {
t.Fatalf("projected icon = %d, want %d", got.Projection().Icon, beta.IconDocumentID)
}
}
// Kill switch: disabling the current verifier hides the badge. The replaced
// alpha mark must not silently reappear.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta: %v", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after disabling beta err=%v, want ErrCustomVerificationNotFound", err)
}
if stored, err := s.CustomVerification(ctx, beta.BotID, peer); err != nil || stored.ID != betaMark.ID {
t.Fatalf("disabled verifier lost its mark: %+v err=%v", stored, err)
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta: %v", err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != betaMark.ID {
t.Fatalf("projection after re-enabling beta = %+v err=%v", got, err)
}
// Granting through alpha replaces beta's mark on the same peer.
regranted, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, IconDocumentID: 5099,
Description: "checked by alpha, again",
})
if err != nil || !created {
t.Fatalf("re-grant alpha mark: created=%v err=%v", created, err)
}
if regranted.ID != betaMark.ID || regranted.Version != betaMark.Version+1 {
t.Fatalf("re-granted mark = %+v, want id %d v%d", regranted, betaMark.ID, betaMark.Version+1)
}
if regranted.IconDocumentID != 5099 || regranted.Description != "checked by alpha, again" {
t.Fatalf("re-granted payload = %+v", regranted)
}
if regranted.CreatedAt.Before(betaMark.CreatedAt) || !regranted.UpdatedAt.After(betaMark.UpdatedAt) {
t.Fatalf("re-granted timestamps = %v / %v", regranted.CreatedAt, regranted.UpdatedAt)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 1 {
t.Fatalf("alpha mark count = %d err=%v", count, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.VerifierBotID != alpha.BotID {
t.Fatalf("projection after re-grant = %+v err=%v", got, err)
}
// The batch form resolves several peers at once, with the same rules.
second := botVerificationChannelPeer(7002)
third := botVerificationUserPeer(7003)
unmarked := botVerificationChannelPeer(7004)
secondMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: second, Description: "second",
})
if err != nil {
t.Fatalf("grant second: %v", err)
}
thirdMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: third, Description: "third",
})
if err != nil {
t.Fatalf("grant third: %v", err)
}
batch, err := s.PeerVerificationBatch(ctx,
[]domain.Peer{peer, second, third, unmarked, peer, {Type: domain.PeerTypeUser, ID: 0}})
if err != nil {
t.Fatalf("batch projection: %v", err)
}
if len(batch) != 3 {
t.Fatalf("batch projection = %+v, want 3 peers", batch)
}
if batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID ||
batch[third].ID != thirdMark.ID {
t.Fatalf("batch projection picked %+v", batch)
}
if _, present := batch[unmarked]; present {
t.Fatal("batch projected an unmarked peer")
}
if empty, err := s.PeerVerificationBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch = %+v err=%v", empty, err)
}
// Disabling a verifier drops its peers from the batch too.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta again: %v", err)
}
batch, err = s.PeerVerificationBatch(ctx, []domain.Peer{peer, second, third})
if err != nil {
t.Fatalf("batch projection after disable: %v", err)
}
if len(batch) != 2 || batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID {
t.Fatalf("batch after disable = %+v", batch)
}
if _, present := batch[third]; present {
t.Fatal("disabled verifier still projects in the batch")
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta again: %v", err)
}
// Listing and paging over the marks.
all, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{})
if err != nil || len(all) != 3 {
t.Fatalf("list marks = %+v err=%v", all, err)
}
if all[0].ID != thirdMark.ID {
t.Fatalf("mark list order = %+v, want newest first", all)
}
page, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("mark page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("mark keyset page = %+v err=%v", next, err)
}
mine, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
VerifierBotID: alpha.BotID,
})
if err != nil || len(mine) != 2 {
t.Fatalf("verifier-filtered marks = %+v err=%v", mine, err)
}
channels, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeChannel,
})
if err != nil || len(channels) != 1 || channels[0].Peer != second {
t.Fatalf("channel-filtered marks = %+v err=%v", channels, err)
}
byPeer, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: peer.Type, PeerID: peer.ID,
})
if err != nil || len(byPeer) != 1 {
t.Fatalf("peer-filtered marks = %+v err=%v", byPeer, err)
}
byQuery, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Query: fmt.Sprintf("%d", second.ID),
})
if err != nil || len(byQuery) != 1 || byQuery[0].ID != secondMark.ID {
t.Fatalf("numeric mark query = %+v err=%v", byQuery, err)
}
byText, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Query: "AGAIN"})
if err != nil || len(byText) != 1 || byText[0].ID != regranted.ID {
t.Fatalf("text mark query = %+v err=%v", byText, err)
}
if _, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeFolder,
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("bad peer-type filter err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// A replaced verifier cannot revoke the current mark.
revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer)
if err != nil || revoked {
t.Fatalf("revoke replaced beta mark: revoked=%v err=%v", revoked, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer); err != nil || revoked {
t.Fatalf("repeated revoke: revoked=%v err=%v", revoked, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != regranted.ID {
t.Fatalf("projection after rejected revoke = %+v err=%v", got, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, alpha.BotID, peer); err != nil || !revoked {
t.Fatalf("revoke alpha mark: revoked=%v err=%v", revoked, err)
}
if _, err := s.RevokeCustomVerification(ctx, 0, peer); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("revoke without verifier err = %v, want ErrCustomVerificationTargetInvalid", err)
}
}
// TestCustomVerificationLimitMemory pins the per-verifier bound: a new mark is
// refused at the limit while an existing one can still be re-described.
func TestCustomVerificationLimitMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 303, 5001)
for i := 1; i <= domain.MaxCustomVerificationsPerVerifier; i++ {
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(int64(i)),
}); err != nil || !created {
t.Fatalf("grant %d: created=%v err=%v", i, created, err)
}
}
if count, err := s.CountCustomVerifications(ctx, verifier.BotID); err != nil ||
count != domain.MaxCustomVerificationsPerVerifier {
t.Fatalf("mark count = %d err=%v", count, err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); !errors.Is(err, domain.ErrCustomVerificationLimit) {
t.Fatalf("grant past the limit err = %v, want ErrCustomVerificationLimit", err)
}
// The bound is on creating marks, not on editing them.
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(7),
Description: "still editable at the limit",
}); err != nil || created {
t.Fatalf("re-grant at the limit: created=%v err=%v", created, err)
}
// Freeing one slot lets the next grant through.
if revoked, err := s.RevokeCustomVerification(ctx, verifier.BotID,
botVerificationChannelPeer(1)); err != nil || !revoked {
t.Fatalf("free a slot: revoked=%v err=%v", revoked, err)
}
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); err != nil || !created {
t.Fatalf("grant into the freed slot: created=%v err=%v", created, err)
}
}
// TestCustomVerificationRequestQueueMemory covers the application queue: one
// pending application per (verifier, peer), the decision status machine, and the
// transaction that keeps an approved application and its mark together.
func TestCustomVerificationRequestQueueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 501, 5001)
peer := botVerificationChannelPeer(8001)
applicant := int64(6001)
grant := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
Description: req.RequestedDescription,
GrantedByUserID: req.ApplicantUserID,
})
return err
}
revoke := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, err := s.RevokeCustomVerification(ctx, req.VerifierBotID, req.Peer)
return err
}
filed, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews"))
if err != nil {
t.Fatalf("file application: %v", err)
}
if filed.Status != domain.CustomVerificationPending || filed.Version != 1 {
t.Fatalf("filed application = %s v%d", filed.Status, filed.Version)
}
if !filed.ApprovedAt.IsZero() || !filed.RejectedAt.IsZero() || filed.DecidedBy != "" {
t.Fatalf("filed application carries a decision: %+v", filed)
}
// custom_verification_requests_pending_idx: one live application per pair.
if _, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews")); !errors.Is(err, domain.ErrCustomVerificationRequestExists) {
t.Fatalf("duplicate pending err = %v, want ErrCustomVerificationRequestExists", err)
}
if _, err := s.CreateCustomVerificationRequest(ctx, domain.CustomVerificationRequest{
VerifierBotID: verifier.BotID, ApplicantUserID: applicant, Peer: peer,
Status: domain.CustomVerificationApproved,
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("pre-decided application err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// The SQL byte bound reserves the worst-case UTF-8 width for the domain's
// rune limit, so valid multi-byte text must behave the same in both stores.
wideStore := NewBotVerificationStore()
wideVerifier := botVerificationTestVerifier(t, wideStore, 909, 5001)
wide := botVerificationTestRequest(wideVerifier.BotID, applicant, botVerificationUserPeer(8009), "Wide")
wide.Reason = strings.Repeat("é", domain.MaxCustomVerificationReasonLength-1)
if _, err := wideStore.CreateCustomVerificationRequest(ctx, wide); err != nil {
t.Fatalf("valid multi-byte reason: %v", err)
}
pending, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer)
if err != nil || pending.ID != filed.ID {
t.Fatalf("pending application = %+v err=%v", pending, err)
}
// A failing callback rolls the whole decision back, including what the
// callback itself wrote before it failed.
boom := errors.New("apply exploded")
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "looks good", "",
func(ctx context.Context, req domain.CustomVerificationRequest) error {
if err := grant(ctx, req); err != nil {
return err
}
return boom
}); !errors.Is(err, boom) {
t.Fatalf("failing apply err = %v, want the callback error", err)
}
rolledBack, err := s.CustomVerificationRequest(ctx, filed.ID)
if err != nil {
t.Fatalf("read after rollback: %v", err)
}
if rolledBack.Status != domain.CustomVerificationPending || rolledBack.Version != filed.Version {
t.Fatalf("application after rollback = %s v%d, want pending v%d",
rolledBack.Status, rolledBack.Version, filed.Version)
}
if !rolledBack.ApprovedAt.IsZero() || rolledBack.DecidedBy != "" {
t.Fatalf("application after rollback carries a decision: %+v", rolledBack)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
// Approving requires a callback: there is no "approved, mark later".
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version+7,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale decision err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationPending, "operator", "", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("decision back to pending err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID+9000, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("decision on unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
approved, changed, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "brand confirmed", "ticket 12", grant)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.CustomVerificationApproved || approved.Version != filed.Version+1 {
t.Fatalf("approved application = %s v%d", approved.Status, approved.Version)
}
if approved.ApprovedAt.IsZero() || !approved.RejectedAt.IsZero() {
t.Fatalf("approved stamps = %v / %v", approved.ApprovedAt, approved.RejectedAt)
}
if approved.DecidedBy != "operator" || approved.DecisionReason != "brand confirmed" ||
approved.InternalNote != "ticket 12" {
t.Fatalf("approved decision metadata = %+v", approved)
}
mark, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after approve: %v", err)
}
if mark.VerifierBotID != verifier.BotID || mark.Description != filed.RequestedDescription ||
mark.IconDocumentID != verifier.IconDocumentID {
t.Fatalf("mark after approve = %+v", mark)
}
if _, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("pending after approve err = %v, want ErrCustomVerificationRequestNotFound", err)
}
// Re-issuing the decision that already holds moves nothing and does not apply
// the callback a second time.
repeat, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationApproved, "someone else", "again", "", func(context.Context, domain.CustomVerificationRequest) error {
t.Fatal("apply ran for a decision that already held")
return nil
})
if err != nil || changed {
t.Fatalf("repeated approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.DecidedBy != "operator" {
t.Fatalf("repeated approve mutated the row: %+v", repeat)
}
// approved -> rejected is not in the status machine; approved -> revoked is.
if _, _, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRejected, "operator", "changed my mind", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("approved -> rejected err = %v, want ErrCustomVerificationRequestInvalid", err)
}
revoked, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRevoked, "operator", "brand asked us to", "", revoke)
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if revoked.Status != domain.CustomVerificationRevoked || revoked.Version != approved.Version+1 {
t.Fatalf("revoked application = %s v%d", revoked.Status, revoked.Version)
}
// The stamps are paired with the status, so leaving approved clears approved_at.
if !revoked.ApprovedAt.IsZero() || !revoked.RejectedAt.IsZero() {
t.Fatalf("revoked stamps = %v / %v", revoked.ApprovedAt, revoked.RejectedAt)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after revoke err = %v, want ErrCustomVerificationNotFound", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, revoked.ID, revoked.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("revoked -> approved err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// A rejection needs a reason, and the domain is what says so.
other := botVerificationUserPeer(8002)
second, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("file second application: %v", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", " ", "", nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without a reason err = %v, want ErrVerificationReasonRequired", err)
}
stillPending, err := s.CustomVerificationRequest(ctx, second.ID)
if err != nil || stillPending.Status != domain.CustomVerificationPending ||
stillPending.Version != second.Version {
t.Fatalf("application after refused rejection = %+v err=%v", stillPending, err)
}
rejected, changed, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", "not a public figure", "", nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.CustomVerificationRejected || rejected.RejectedAt.IsZero() ||
!rejected.ApprovedAt.IsZero() {
t.Fatalf("rejected application = %+v", rejected)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, other); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("rejection granted a mark: %v", err)
}
// A decided pair is free again: history keeps the rejection.
reapplied, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("re-apply after rejection: %v", err)
}
counts, err := s.CustomVerificationRequestCounts(ctx)
if err != nil {
t.Fatalf("queue counts: %v", err)
}
if counts[domain.CustomVerificationPending] != 1 ||
counts[domain.CustomVerificationRejected] != 1 ||
counts[domain.CustomVerificationRevoked] != 1 {
t.Fatalf("queue counts = %+v", counts)
}
if _, present := counts[domain.CustomVerificationApproved]; present {
t.Fatalf("queue counts invented an approved application: %+v", counts)
}
listed, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{})
if err != nil || len(listed) != 3 {
t.Fatalf("queue list = %+v err=%v", listed, err)
}
if listed[0].ID != reapplied.ID {
t.Fatalf("queue order = %+v, want newest first", listed)
}
pendingOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{domain.CustomVerificationPending},
})
if err != nil || len(pendingOnly) != 1 || pendingOnly[0].ID != reapplied.ID {
t.Fatalf("pending queue = %+v err=%v", pendingOnly, err)
}
page, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("queue page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("queue keyset page = %+v err=%v", next, err)
}
byUsername, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: "@acmec",
})
if err != nil || len(byUsername) != 2 {
t.Fatalf("username query = %+v err=%v", byUsername, err)
}
byPeerID, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: fmt.Sprintf("%d", peer.ID),
})
if err != nil || len(byPeerID) != 1 || byPeerID[0].ID != revoked.ID {
t.Fatalf("numeric query = %+v err=%v", byPeerID, err)
}
channelsOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
PeerType: domain.PeerTypeChannel, VerifierBotID: verifier.BotID,
})
if err != nil || len(channelsOnly) != 1 || channelsOnly[0].ID != revoked.ID {
t.Fatalf("channel queue = %+v err=%v", channelsOnly, err)
}
if _, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{"nonsense"},
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("bad status filter err = %v, want ErrCustomVerificationRequestInvalid", err)
}
history, err := s.CustomVerificationRequestsForApplicant(ctx, applicant, 0)
if err != nil || len(history) != 3 || history[0].ID != reapplied.ID {
t.Fatalf("applicant history = %+v err=%v", history, err)
}
if empty, err := s.CustomVerificationRequestsForApplicant(ctx, applicant+1, 0); err != nil ||
len(empty) != 0 {
t.Fatalf("other applicant history = %+v err=%v", empty, err)
}
if _, err := s.CustomVerificationRequestsForApplicant(ctx, 0, 0); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("history for applicant 0 err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, err := s.CustomVerificationRequest(ctx, reapplied.ID+9000); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
}

View file

@ -0,0 +1,687 @@
package memory
import (
"context"
"sort"
"strings"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes applied when an admin filter leaves the limit unset. The
// PostgreSQL queries page with LIMIT, so an unset limit has to resolve to a
// finite page in both backends.
const (
defaultCollectibleUsernameListLimit = 50
defaultCollectibleUsernameTransferLimit = 50
)
// CollectibleUsernameStore is the in-memory implementation of both
// store.UsernameRegistryStore and store.CollectibleUsernameStore. RPC unit tests
// run against it, so it reproduces every invariant migration 0150 encodes as an
// index or CHECK constraint and returns the same domain errors PostgreSQL maps
// its violations onto:
//
// - peer_usernames_peer_editable_idx: exactly one editable row per peer.
// - peer_usernames_collectible_not_editable_check: a row backed by an asset is
// never editable, so client-driven username edits cannot move an asset.
// - peer_usernames.username_lower UNIQUE: global, case-insensitive name
// uniqueness across users and channels, keyed by lower(username).
// - collectible_usernames.username_lower UNIQUE: a name is minted at most
// once. A burn therefore releases the registry row -- the name can be
// occupied again as a peer username -- while the asset row keeps the name
// for provenance and blocks a second mint.
// - the status/owner CHECK pair: owner is populated exactly for status 'owned'.
// - collectible_username_transfers_command_idx: command keys are globally
// unique, which is what makes mint/transfer/revoke replay-safe.
type CollectibleUsernameStore struct {
mu sync.Mutex
nextAssetID int64
nextTransferID int64
// assets is collectible_usernames keyed by identity.
assets map[int64]domain.CollectibleUsername
// assetsByName resolves a name onto the asset it currently stands for. After
// migration 0152 uniqueness covers live rows only, so one name can accumulate
// several burned rows plus at most one live row; this index points at the live
// row when there is one and at the newest burned row otherwise, mirroring the
// SQL lookup order.
assetsByName map[string]int64
// registry is peer_usernames keyed by username_lower, which is exactly how
// the table enforces global uniqueness.
registry map[string]collectibleRegistryRow
// transfers is the append-only provenance log per asset.
transfers map[int64][]domain.CollectibleUsernameTransfer
// commands maps a provenance command key onto the asset it touched.
commands map[string]int64
}
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
// projected username shape.
type collectibleRegistryRow struct {
peer domain.Peer
row domain.Username
}
// NewCollectibleUsernameStore creates an empty registry. Asset ids start at 1 so
// a zero CollectibleID keeps meaning "editable slot", matching the nullable
// collectible_id column.
func NewCollectibleUsernameStore() *CollectibleUsernameStore {
return &CollectibleUsernameStore{
nextAssetID: 1,
nextTransferID: 1,
assets: make(map[int64]domain.CollectibleUsername),
assetsByName: make(map[string]int64),
registry: make(map[string]collectibleRegistryRow),
transfers: make(map[int64][]domain.CollectibleUsernameTransfer),
commands: make(map[string]int64),
}
}
// SetEditableUsername writes the peer's editable slot, mirroring the
// replace-then-insert the PostgreSQL user and channel stores run inside
// account.updateUsername / channels.updateUsername. The memory backend keeps
// usernames on the user and channel rows, so tests need this hook to give a peer
// the editable registry row the projection expects. An empty username clears the
// slot.
func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer domain.Peer, username string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
if username == "" {
return s.clearEditableLocked(peer), nil
}
// peer_usernames has no length CHECK; the 5..32 editable rule lives in the
// service layer. Only the character rules are a registry concern.
if !domain.ValidCollectibleUsername(username) {
return false, domain.ErrUsernameInvalid
}
key := strings.ToLower(username)
if existing, ok := s.registry[key]; ok {
if existing.peer == peer && existing.row.Editable {
if existing.row.Username == username {
return false, nil
}
existing.row.Username = username
s.registry[key] = existing
return true, nil
}
return false, domain.ErrUsernameOccupied
}
// A live asset owns its name even while it sits in the vault: only a burn
// puts the name back into the free pool.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return false, domain.ErrUsernameOccupied
}
s.clearEditableLocked(peer)
s.registry[key] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: username,
Active: true,
Editable: true,
},
}
return true, nil
}
// PeerUsernames returns the peer's registry rows in projection order.
func (s *CollectibleUsernameStore) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.peerUsernamesLocked(peer), nil
}
// PeerUsernamesBatch resolves several peers at once; peers holding no username
// are absent from the result.
func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
out := make(map[domain.Peer][]domain.Username, len(peers))
if len(peers) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, peer := range peers {
if !validCollectibleUsernamePeer(peer) {
continue
}
if _, done := out[peer]; done {
continue
}
rows := s.peerUsernamesLocked(peer)
if len(rows) == 0 {
continue
}
out[peer] = rows
}
return out, nil
}
// SetUsernameActive toggles one collectible row. The domain validator owns the
// rules: the editable slot is off limits and a peer that holds usernames must
// keep at least one active.
func (s *CollectibleUsernameStore) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
key := strings.ToLower(username)
entry := s.registry[key]
if entry.row.Active == active {
return false, nil
}
entry.row.Active = active
s.registry[key] = entry
return true, nil
}
// ReorderUsernames rewrites the peer's username sort order, editable slot
// included. Validation and the resulting order both come from the domain helper,
// so the two backends cannot drift.
func (s *CollectibleUsernameStore) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
reordered, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return false, err
}
// Renumbering always happens; "changed" is about what a client can see.
changed := !domain.SameUsernameOrder(current, reordered)
for _, row := range reordered {
key := strings.ToLower(row.Username)
if key == "" {
continue
}
entry := s.registry[key]
if entry.row.SortOrder == row.SortOrder {
continue
}
entry.row.SortOrder = row.SortOrder
s.registry[key] = entry
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row and
// leaves the editable slot alone.
func (s *CollectibleUsernameStore) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
changed := false
for key, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() || !entry.row.Active {
continue
}
entry.row.Active = false
s.registry[key] = entry
changed = true
}
return changed, nil
}
// MintCollectibleUsername creates the asset, optionally assigning it in the same
// call. A replayed command key returns the recorded asset with created=false.
func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
key := strings.ToLower(req.Username)
// Only a live asset occupies a name. A name whose history is entirely burned
// is free to be issued again, and the new asset takes over the index entry
// while the burned rows stay as provenance.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if _, ok := s.registry[key]; ok {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
now := time.Now().UTC()
purchaseDate := req.PurchaseDate
if purchaseDate.IsZero() {
purchaseDate = now
}
asset := domain.CollectibleUsername{
ID: s.nextAssetID,
Username: req.Username,
Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: purchaseDate,
Currency: req.Currency,
Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency,
CryptoAmount: req.CryptoAmount,
URL: req.URL,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
// The first holder is the original owner and survives every later move.
asset.OriginalOwner = req.Owner
}
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Owned() && s.countCollectiblesLocked(req.Owner) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
s.nextAssetID++
s.assets[asset.ID] = asset
s.assetsByName[key] = asset.ID
if asset.Owned() {
s.attachLocked(asset, req.Owner)
}
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindMint,
To: req.Owner,
Currency: req.Currency,
Amount: req.Amount,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// TransferCollectibleUsername moves the asset out of the vault or between
// holders. Handing the asset to the peer that already holds it is a no-op.
func (s *CollectibleUsernameStore) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if asset.Owned() && asset.Owner == req.To {
return asset, false, nil
}
key := strings.ToLower(asset.Username)
// Defensive: the registry row for this name must be the asset's own. A live
// asset occupies its name globally, so the only way another row can hold it
// is an inconsistently seeded store.
if existing, ok := s.registry[key]; ok && existing.row.CollectibleID != asset.ID {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if s.countCollectiblesLocked(req.To) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
from := asset.Owner
now := time.Now().UTC()
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
if asset.OriginalOwner.Type == "" {
asset.OriginalOwner = req.To
}
asset.TransferCount++
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.attachLocked(asset, req.To)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindTransfer,
From: from,
To: req.To,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// RevokeCollectibleUsername returns the asset to the vault, or burns it.
//
// A revoke keeps the name owned by the asset -- nobody else can take it -- while
// a burn drops the registry row and releases the name back to the free pool. The
// burned asset row itself survives with its name so provenance stays readable
// and the name never mints twice.
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if !req.Burn && !asset.Owned() {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotOwned
}
from := asset.Owner
now := time.Now().UTC()
kind := domain.CollectibleUsernameKindRevoke
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
kind = domain.CollectibleUsernameKindBurn
asset.Status = domain.CollectibleUsernameStatusBurned
}
asset.Owner = domain.Peer{}
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: kind,
From: from,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// CollectibleUsername looks the asset up by name, case-insensitively.
// DeleteCollectibleUsername removes the live asset for a name completely --
// registry row, asset and provenance -- and frees the name for any use. Revoke
// with Burn retires an asset but keeps its history; this is the escape hatch for
// an asset issued by mistake.
//
// A command key cannot make this idempotent: the record it would resolve to is
// gone. A repeated call therefore reports deleted=false once no live asset is
// left, which is also what a delete of a burned-only name reports.
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
if s == nil {
return false, nil
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
s.mu.Lock()
defer s.mu.Unlock()
key := strings.ToLower(req.Username)
id, ok := s.assetsByName[key]
if !ok {
return false, nil
}
asset, ok := s.assets[id]
if !ok || asset.Status == domain.CollectibleUsernameStatusBurned {
return false, nil
}
s.detachLocked(id)
delete(s.assets, id)
delete(s.transfers, id)
for commandKey, target := range s.commands {
if target == id {
delete(s.commands, commandKey)
}
}
s.rebindAssetNameLocked(key)
return true, nil
}
// rebindAssetNameLocked re-points the name index after a row disappears: the
// newest remaining row wins, and the entry is dropped when none is left.
func (s *CollectibleUsernameStore) rebindAssetNameLocked(key string) {
best := int64(0)
for id, asset := range s.assets {
if strings.ToLower(asset.Username) != key {
continue
}
if best == 0 || id > best {
best = id
}
}
if best == 0 {
delete(s.assetsByName, key)
return
}
s.assetsByName[key] = best
}
func (s *CollectibleUsernameStore) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.assetByNameLocked(domain.NormalizeUsername(username))
}
// CollectibleUsernameByID looks the asset up by identity.
func (s *CollectibleUsernameStore) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// ListCollectibleUsernames is the admin listing query: newest first, paged by a
// BeforeID keyset, matching collectible_usernames_status_idx ordering.
func (s *CollectibleUsernameStore) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.CollectibleUsername, 0, len(s.assets))
for _, asset := range s.assets {
if filter.Status != "" && asset.Status != filter.Status {
continue
}
if filter.Owner.Type != "" && asset.Owner != filter.Owner {
continue
}
if query != "" && !strings.Contains(strings.ToLower(asset.Username), query) {
continue
}
if filter.BeforeID > 0 && asset.ID >= filter.BeforeID {
continue
}
out = append(out, asset)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// CollectibleUsernameTransfers returns the provenance log newest first.
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if limit <= 0 {
limit = defaultCollectibleUsernameTransferLimit
}
s.mu.Lock()
defer s.mu.Unlock()
stored := s.transfers[collectibleID]
out := make([]domain.CollectibleUsernameTransfer, 0, len(stored))
for i := len(stored) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, stored[i])
}
return out, nil
}
// peerUsernamesLocked collects the peer's rows in projection order. The rows are
// values, so the returned slice cannot be used to mutate stored state.
func (s *CollectibleUsernameStore) peerUsernamesLocked(peer domain.Peer) []domain.Username {
rows := make([]domain.Username, 0, 4)
for _, entry := range s.registry {
if entry.peer != peer {
continue
}
rows = append(rows, entry.row)
}
return domain.SortUsernames(rows)
}
// countCollectiblesLocked counts the peer's collectible registry rows, which is
// what MaxPeerCollectibleUsernames bounds.
func (s *CollectibleUsernameStore) countCollectiblesLocked(peer domain.Peer) int {
count := 0
for _, entry := range s.registry {
if entry.peer == peer && entry.row.Collectible() {
count++
}
}
return count
}
// nextSortOrderLocked appends the new collectible after the peer's existing ones,
// clamped by the registry sort_order CHECK.
func (s *CollectibleUsernameStore) nextSortOrderLocked(peer domain.Peer) int {
next := 0
for _, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() {
continue
}
if entry.row.SortOrder >= next {
next = entry.row.SortOrder + 1
}
}
if next > domain.MaxUsernameSortOrder {
next = domain.MaxUsernameSortOrder
}
return next
}
// attachLocked projects an owned asset into the registry. Callers check
// occupancy and the per-peer bound first, exactly like the PostgreSQL path does
// before it hits the unique index.
func (s *CollectibleUsernameStore) attachLocked(asset domain.CollectibleUsername, peer domain.Peer) {
s.registry[strings.ToLower(asset.Username)] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: asset.Username,
Active: true,
Editable: false,
SortOrder: s.nextSortOrderLocked(peer),
CollectibleID: asset.ID,
},
}
}
// detachLocked removes the registry row backed by the asset, leaving the peer's
// editable slot and its other collectibles untouched.
func (s *CollectibleUsernameStore) detachLocked(collectibleID int64) {
for key, entry := range s.registry {
if entry.row.CollectibleID == collectibleID {
delete(s.registry, key)
return
}
}
}
// clearEditableLocked drops the peer's editable row, keeping the one-editable-row
// index true by construction.
func (s *CollectibleUsernameStore) clearEditableLocked(peer domain.Peer) bool {
for key, entry := range s.registry {
if entry.peer == peer && entry.row.Editable {
delete(s.registry, key)
return true
}
}
return false
}
func (s *CollectibleUsernameStore) assetByNameLocked(username string) (domain.CollectibleUsername, error) {
if username == "" {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
id, ok := s.assetsByName[strings.ToLower(username)]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// replayLocked resolves a command key onto the asset the recorded command
// touched. The provenance command index is global, so a replayed key is a no-op
// for every kind, just like the INSERT ... ON CONFLICT DO NOTHING path.
func (s *CollectibleUsernameStore) replayLocked(commandKey string) (domain.CollectibleUsername, bool) {
if commandKey == "" {
return domain.CollectibleUsername{}, false
}
id, ok := s.commands[commandKey]
if !ok {
return domain.CollectibleUsername{}, false
}
asset, ok := s.assets[id]
return asset, ok
}
func (s *CollectibleUsernameStore) recordTransferLocked(entry domain.CollectibleUsernameTransfer) {
entry.ID = s.nextTransferID
s.nextTransferID++
s.transfers[entry.CollectibleID] = append(s.transfers[entry.CollectibleID], entry)
if entry.CommandKey != "" {
s.commands[entry.CommandKey] = entry.CollectibleID
}
}
// validCollectibleUsernamePeer mirrors the peer_type CHECK: only real user and
// channel peers can hold a username.
func validCollectibleUsernamePeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}

View 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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,760 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
// verificationTestDraft is a payload that clears domain.ValidateForSubmission:
// a category, a long enough description, a website and two independent press
// links.
func verificationTestDraft() domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: "media",
Description: strings.Repeat("independent newsroom covering the region ", 2),
OfficialWebsite: "https://example.com",
SocialLinks: []string{"https://t.me/example"},
PressLinks: []string{
"https://press.example.com/story",
"https://press.example.org/profile",
},
AdditionalNote: "filed through the bot dialog",
}
}
func verificationTestRequest(applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicant,
TargetType: targetType,
TargetID: targetID,
TargetTitle: "Target " + username,
TargetUsername: username,
Draft: verificationTestDraft(),
CorrelationID: fmt.Sprintf("corr-%d", targetID),
}
}
// submittedVerificationApplication drives the applicant path up to the review
// queue, which is the state every reviewer test starts from.
func submittedVerificationApplication(t *testing.T, s *VerificationStore, applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.VerificationApplication {
t.Helper()
ctx := context.Background()
app, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(applicant, targetType, targetID, username))
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
app, err = s.SubmitVerificationApplication(ctx, app.ID, app.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
return app
}
// TestMemoryVerificationDraftLifecycle covers the applicant path: a draft is
// opened once and resumed on the next /start, the payload is stored verbatim, and
// submission stamps the queue entry.
func TestMemoryVerificationDraftLifecycle(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
req := verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot")
req.Draft = domain.VerificationDraftInput{Category: "media"}
app, created, err := s.CreateVerificationDraft(ctx, req)
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
if app.Status != domain.VerificationStatusDraft || app.Version != 1 {
t.Fatalf("draft state = %s v%d, want draft v1", app.Status, app.Version)
}
if app.TargetUsername != "AlphaBot" || app.CorrelationID != "corr-5001" {
t.Fatalf("draft snapshot = %q / %q", app.TargetUsername, app.CorrelationID)
}
if !app.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
t.Fatal("fresh draft carries review metadata")
}
// The bot dialog is one conversation: a second /start resumes the draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot"))
if err != nil || created {
t.Fatalf("resume draft: created=%v err=%v", created, err)
}
if resumed.ID != app.ID || resumed.Version != app.Version {
t.Fatalf("resumed draft = %d v%d, want %d v%d", resumed.ID, resumed.Version, app.ID, app.Version)
}
if _, err := s.SubmitVerificationApplication(ctx, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("submit incomplete draft err = %v, want ErrVerificationApplicationInvalid", err)
}
saved, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft())
if err != nil {
t.Fatalf("save draft: %v", err)
}
if saved.Version != app.Version+1 || len(saved.PressLinks) != 2 || saved.OfficialWebsite != "https://example.com" {
t.Fatalf("saved draft = v%d links=%v site=%q", saved.Version, saved.PressLinks, saved.OfficialWebsite)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft()); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale save err = %v, want ErrVerificationVersionConflict", err)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, saved.Version, domain.VerificationDraftInput{OfficialWebsite: "http://127.0.0.1/x"}); !errors.Is(err, domain.ErrVerificationURLInvalid) {
t.Fatalf("private-host save err = %v, want ErrVerificationURLInvalid", err)
}
submitted, err := s.SubmitVerificationApplication(ctx, saved.ID, saved.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
if submitted.Status != domain.VerificationStatusSubmitted || submitted.SubmittedAt.IsZero() {
t.Fatalf("submitted = %s at %v", submitted.Status, submitted.SubmittedAt)
}
if !submitted.ReviewedAt.IsZero() || submitted.ReviewerAdminID != "" {
t.Fatal("submitted application carries a reviewer")
}
if _, err := s.VerificationDraftForApplicant(ctx, 1001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
}
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, 5001)
if err != nil || active.ID != app.ID {
t.Fatalf("active for target = %d err=%v", active.ID, err)
}
// Returned slices are copies: mutating them must not reach the store.
submitted.PressLinks[0] = "https://evil.example.com"
reread, err := s.VerificationApplication(ctx, app.ID)
if err != nil || reread.PressLinks[0] != "https://press.example.com/story" {
t.Fatalf("stored press links mutated through the caller: %v err=%v", reread.PressLinks, err)
}
}
// TestMemoryVerificationActiveTargetUniqueness is the partial unique index: one
// live application per target, and a decided one no longer blocks a fresh
// attempt.
func TestMemoryVerificationActiveTargetUniqueness(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 6001, "beta")
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetChannel, 6001, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
}
// A different target is free for the same target id in another namespace.
namespaced, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6001, "betabot"))
if err != nil {
t.Fatalf("other namespace draft: %v", err)
}
// One draft per applicant: naming another target resumes the same
// conversation instead of opening a second draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6002, "betabot2"))
if err != nil || created || resumed.ID != namespaced.ID {
t.Fatalf("cross-target draft = %d created=%v err=%v, want draft %d", resumed.ID, created, err, namespaced.ID)
}
cancelled, err := s.CancelVerificationApplication(ctx, first.ID, first.Version, "changed my mind")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if cancelled.Status != domain.VerificationStatusCancelled || cancelled.SubmittedAt.IsZero() {
t.Fatalf("cancelled = %s at %v", cancelled.Status, cancelled.SubmittedAt)
}
if cancelled.DecisionReason != "" {
t.Fatalf("cancel wrote the applicant reason into decision_reason: %q", cancelled.DecisionReason)
}
if _, err := s.CancelVerificationApplication(ctx, cancelled.ID, cancelled.Version, "again"); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("cancel of cancelled err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetChannel, 6001, "beta")); err != nil {
t.Fatalf("draft after cancellation: %v", err)
}
}
// TestMemoryVerificationClaimAndApprove is the reviewer path, including the
// invariant that matters most: the peer flag is written by the callback and the
// application is only approved if that callback succeeded.
func TestMemoryVerificationClaimAndApprove(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 7001, "gamma")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if claimed.Status != domain.VerificationStatusInReview || claimed.ReviewerAdminID != "admin-a" {
t.Fatalf("claimed = %s by %q", claimed.Status, claimed.ReviewerAdminID)
}
if !claimed.ReviewedAt.IsZero() {
t.Fatal("claim stamped reviewed_at, which the schema pairs with a decision")
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-b",
}); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("re-claim err = %v, want ErrVerificationStatusInvalid", err)
}
// A callback failure must roll the whole decision back.
failing := errors.New("peer store unavailable")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing approve err = %v, want %v", err, failing)
}
rolled, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read after failed approve: %v", err)
}
if rolled.Status != domain.VerificationStatusInReview || rolled.Version != claimed.Version {
t.Fatalf("failed approve left %s v%d, want in_review v%d", rolled.Status, rolled.Version, claimed.Version)
}
if !rolled.ReviewedAt.IsZero() || rolled.DecisionReason != "" {
t.Fatal("failed approve wrote decision metadata")
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("outbox after failed approve = %d rows err=%v", len(pending), err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 10)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventApproved {
t.Fatal("failed approve appended an approved event")
}
}
verified := make(map[domain.Peer]bool)
applyVerified := func(_ context.Context, decided domain.VerificationApplication) error {
if decided.Status != domain.VerificationStatusApproved {
return fmt.Errorf("callback saw %s, want approved", decided.Status)
}
verified[decided.Target()] = true
return nil
}
approved, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
InternalNote: "checked the press coverage", CorrelationID: "cmd-1",
}, true, applyVerified)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.VerificationStatusApproved || approved.ReviewedAt.IsZero() ||
approved.ReviewerAdminID != "admin-a" || approved.Version != claimed.Version+1 {
t.Fatalf("approved = %s v%d by %q at %v", approved.Status, approved.Version,
approved.ReviewerAdminID, approved.ReviewedAt)
}
if !verified[approved.Target()] {
t.Fatal("approved application whose target is not verified")
}
// Re-issuing the decision must not notify twice.
repeat, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-b",
}, true, func(context.Context, domain.VerificationApplication) error {
t.Fatal("idempotent approve invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.ReviewerAdminID != "admin-a" {
t.Fatalf("repeat approve mutated the record: v%d by %q", repeat.Version, repeat.ReviewerAdminID)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0].Kind != "approved" || pending[0].RecipientUserID != 1001 {
t.Fatalf("outbox = %+v, want one approved row for the applicant", pending)
}
if pending[0].Application.ID != app.ID || pending[0].Application.TargetUsername != "gamma" {
t.Fatalf("outbox row carries no application context: %+v", pending[0].Application)
}
// A decided application is terminal.
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a", Reason: "changed our mind",
}, false, nil); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("reject after approve err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a",
}, true, nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
}
// TestMemoryVerificationRejectRequiresReason keeps the audit trail honest: a
// rejection the applicant is told about always states why.
func TestMemoryVerificationRejectRequiresReason(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, false, nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err = %v, want ErrVerificationReasonRequired", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: " ", Reason: "not eligible",
}, false, nil); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("reject without reviewer err = %v, want ErrVerificationApplicationInvalid", err)
}
rejected, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
Reason: "press coverage is not independent", InternalNote: "second attempt this month",
}, false, nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.VerificationStatusRejected || rejected.DecisionReason == "" {
t.Fatalf("rejected = %s reason=%q", rejected.Status, rejected.DecisionReason)
}
cooldown, err := s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != app.ID {
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
}
if _, err := s.LastVerificationRejection(ctx, 1002, domain.VerificationTargetChannel, 8001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
}
// The rejected application is history, so the target is free again, and the
// newest rejection is the one the cooldown is measured from.
second := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: second.ID, Version: second.Version, Reviewer: "admin-b", Reason: "still no",
}, false, nil); err != nil {
t.Fatalf("second reject: %v", err)
}
cooldown, err = s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != second.ID {
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
}
}
// TestMemoryVerificationConcurrentDecision is the two-reviewers case: both read
// the same version, exactly one decision lands and the loser is told.
func TestMemoryVerificationConcurrentDecision(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9001, "epsilon")
calls := 0
applyVerified := func(context.Context, domain.VerificationApplication) error {
calls++
return nil
}
if _, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, applyVerified); err != nil || !changed {
t.Fatalf("first approve: changed=%v err=%v", changed, err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b",
}, true, applyVerified); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("second approve err = %v, want ErrVerificationVersionConflict", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b", Reason: "no",
}, false, nil); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("losing reject err = %v, want ErrVerificationVersionConflict", err)
}
if calls != 1 {
t.Fatalf("applyVerified called %d times, want exactly 1", calls)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("outbox = %d rows err=%v, want exactly one notification", len(pending), err)
}
final, err := s.VerificationApplication(ctx, app.ID)
if err != nil || final.ReviewerAdminID != "admin-a" {
t.Fatalf("final decision by %q err=%v, want admin-a", final.ReviewerAdminID, err)
}
}
// TestMemoryVerificationRevoke covers taking the badge back: the flag is cleared
// through the callback, the application stays approved as history and the
// revocation notifies exactly once.
func TestMemoryVerificationRevoke(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 9101, "zeta")
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error { return nil })
if err != nil {
t.Fatalf("approve: %v", err)
}
req := domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101,
Reviewer: "admin-b", Reason: "impersonation report upheld",
}
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101, Reviewer: "admin-b",
}, func(context.Context, domain.Peer) error { return nil }); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err = %v, want ErrVerificationReasonRequired", err)
}
failing := errors.New("peer store unavailable")
if _, _, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing revoke err = %v, want %v", err, failing)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventRevoked {
t.Fatal("failed revoke appended a revoked event")
}
}
var cleared []domain.Peer
revoked, changed, err := s.RevokeVerification(ctx, req, func(_ context.Context, target domain.Peer) error {
cleared = append(cleared, target)
return nil
})
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if len(cleared) != 1 || cleared[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 9101}) {
t.Fatalf("cleared = %v, want the channel peer", cleared)
}
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
}
// A second revocation is a no-op: one outbox row, one history entry.
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
t.Fatal("idempotent revoke invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat revoke: changed=%v err=%v", changed, err)
}
if repeat.ID != approved.ID {
t.Fatalf("repeat revoke returned %d, want %d", repeat.ID, approved.ID)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
kinds := make([]string, 0, len(pending))
for _, item := range pending {
kinds = append(kinds, item.Kind)
}
if len(kinds) != 2 || kinds[0] != "approved" || kinds[1] != "revoked" {
t.Fatalf("outbox kinds = %v, want [approved revoked] in that order", kinds)
}
// A target nobody ever applied for is still cleared: a standing flag is worse
// than a missing audit row.
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: 9999,
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
}, func(context.Context, domain.Peer) error { return nil })
if err != nil || !changed || orphan.ID != 0 {
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
}
}
// TestMemoryVerificationHistoryOrder pins the append-only timeline: newest first,
// with the from/to statuses of every transition.
func TestMemoryVerificationHistoryOrder(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9201, "eta")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a", CorrelationID: "cmd-9",
}, true, func(context.Context, domain.VerificationApplication) error { return nil }); err != nil {
t.Fatalf("approve: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("pending = %d err=%v", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, pending[0].ID); err != nil {
t.Fatalf("deliver: %v", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
wantKinds := []domain.VerificationApplicationEventKind{
domain.VerificationEventNotified,
domain.VerificationEventApproved,
domain.VerificationEventClaimed,
domain.VerificationEventSubmitted,
domain.VerificationEventCreated,
}
if len(events) != len(wantKinds) {
t.Fatalf("history = %d rows, want %d", len(events), len(wantKinds))
}
for i, kind := range wantKinds {
if events[i].Kind != kind {
t.Fatalf("history[%d] = %s, want %s", i, events[i].Kind, kind)
}
if i > 0 && events[i].ID >= events[i-1].ID {
t.Fatalf("history is not newest-first at %d: %d >= %d", i, events[i].ID, events[i-1].ID)
}
}
approvedEvent := events[1]
if approvedEvent.FromStatus != domain.VerificationStatusInReview ||
approvedEvent.ToStatus != domain.VerificationStatusApproved ||
approvedEvent.Actor != "admin-a" || approvedEvent.CorrelationID != "cmd-9" {
t.Fatalf("approved event = %+v", approvedEvent)
}
if events[3].FromStatus != domain.VerificationStatusDraft ||
events[3].ToStatus != domain.VerificationStatusSubmitted {
t.Fatalf("submitted event = %+v", events[3])
}
}
// TestMemoryVerificationOutboxDelivery walks one notification from pending to
// delivered, including the failure trace a poisoned row leaves behind.
func TestMemoryVerificationOutboxDelivery(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9301, "theta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a", Reason: "no press",
}, false, nil); err != nil {
t.Fatalf("reject: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 0 {
t.Fatalf("pending = %+v err=%v", pending, err)
}
id := pending[0].ID
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
t.Fatalf("fail: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 1 {
t.Fatalf("after failure = %+v err=%v, want still pending with 1 attempt", pending, err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("deliver: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("after delivery = %d rows err=%v, want none", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("repeat deliver: %v", err)
}
if err := s.MarkVerificationNotificationFailed(ctx, id, "late error"); err != nil {
t.Fatalf("fail after delivery: %v", err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id+1000); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("deliver unknown id err = %v, want ErrVerificationApplicationNotFound", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
if events[0].Kind != domain.VerificationEventNotified || events[0].Reason != "rejected" {
t.Fatalf("notified event = %+v, want the rejected notification", events[0])
}
}
// TestMemoryVerificationQueueQueries covers the review-queue projection: status
// and target filters, reviewer scoping, the three search shapes and keyset paging.
func TestMemoryVerificationQueueQueries(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9401, "AlphaBot")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: first.ID, Version: first.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
second := submittedVerificationApplication(t, s, 1002, domain.VerificationTargetChannel, 9402, "BetaChannel")
third, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetSupergroup, 9403, "gammagroup"))
if err != nil {
t.Fatalf("third draft: %v", err)
}
ids := func(apps []domain.VerificationApplication) []int64 {
out := make([]int64, 0, len(apps))
for _, app := range apps {
out = append(out, app.ID)
}
return out
}
equal := func(got []int64, want ...int64) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
all, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if !equal(ids(all), third.ID, second.ID, first.ID) {
t.Fatalf("queue order = %v, want newest first", ids(all))
}
got, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
})
if err != nil || !equal(ids(got), second.ID, first.ID) {
t.Fatalf("status filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetChannel,
})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("target type filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-a"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("reviewer filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-z"})
if err != nil || len(got) != 0 {
t.Fatalf("unknown reviewer = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: second.CreatedAt,
})
if err != nil || !equal(ids(got), third.ID, second.ID) {
t.Fatalf("since filter = %v err=%v", ids(got), err)
}
// BeforeID without a cursor timestamp still bounds the page.
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
BeforeID: second.ID,
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id-only cursor = %v err=%v", ids(got), err)
}
// Search: application id, peer id, username prefix (with and without @).
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Query: fmt.Sprint(first.ID),
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "9402"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("peer id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "@beta"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "ALPHA"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("case-insensitive username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "nobody"})
if err != nil || len(got) != 0 {
t.Fatalf("miss search = %v err=%v", ids(got), err)
}
// Keyset paging over (created_at DESC, id DESC).
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 2})
if err != nil || !equal(ids(page), third.ID, second.ID) {
t.Fatalf("first page = %v err=%v", ids(page), err)
}
last := page[len(page)-1]
next, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || !equal(ids(next), first.ID) {
t.Fatalf("second page = %v err=%v", ids(next), err)
}
last = next[len(next)-1]
tail, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || len(tail) != 0 {
t.Fatalf("third page = %v err=%v, want empty", ids(tail), err)
}
counts, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
if counts[domain.VerificationStatusInReview] != 1 ||
counts[domain.VerificationStatusSubmitted] != 1 ||
counts[domain.VerificationStatusDraft] != 1 ||
counts[domain.VerificationStatusApproved] != 0 {
t.Fatalf("counts = %v", counts)
}
mine, err := s.VerificationApplicationsForApplicant(ctx, 1001, 10)
if err != nil || !equal(ids(mine), first.ID) {
t.Fatalf("applicant history = %v err=%v", ids(mine), err)
}
_ = claimed
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{"bogus"},
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("bogus status filter err = %v, want ErrVerificationApplicationInvalid", err)
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: "bogus",
}); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("bogus target filter err = %v, want ErrVerificationTargetInvalid", err)
}
}
// TestMemoryVerificationMissingApplication pins the not-found surface every
// mutation shares.
func TestMemoryVerificationMissingApplication(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
if _, err := s.VerificationApplication(ctx, 42); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("read err = %v", err)
}
if _, err := s.SubmitVerificationApplication(ctx, 42, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("submit err = %v", err)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim err = %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return nil
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("decide err = %v", err)
}
}