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